diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 0f3ceb372ba..efe2053d258 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -26,6 +26,44 @@ pnpm install
pnpm dev
```
+### Dev Profiles and Parallel Instances
+
+`pnpm dev` in a linked Git worktree uses its own userData profile, renderer port, and remote
+debugging (CDP) port, so dev instances in different worktrees run side by side. The main checkout
+keeps the shared `orca-dev` profile. Ports are derived from the worktree path and move to the next
+free port when taken: renderer ports start at 15200 and CDP ports at 9333.
+
+On startup the runner prints one stdout line and writes the same JSON to `out/dev-instance.json`:
+
+```text
+ORCA_DEV_INSTANCE {"userData":".../orca-dev-wt/1d3830d4d3","profile":"worktree","rendererPort":15339,"rendererUrl":"http://localhost:15339","cdpPort":9513,"cdpUrl":"http://127.0.0.1:9513","repoRoot":"...","runnerPid":4242}
+```
+
+| Option | Effect |
+| ----------------------------------------------------------------------- | ---------------------------------------------------------- |
+| `pnpm dev --shared-profile` or `ORCA_DEV_PROFILE=shared` | Use the shared `orca-dev` profile |
+| `pnpm dev --worktree-profile` or `ORCA_DEV_PROFILE=worktree` | Use a per-worktree profile, including in the main checkout |
+| `ORCA_DEV_USER_DATA_PATH=
` | Use an explicit profile directory |
+| `REMOTE_DEBUGGING_PORT=`, `ORCA_DEV_RENDERER_PORT=` | Pin a port |
+| `pnpm dev --seed-profile[=]` or `ORCA_DEV_SEED_PROFILE=` | Seed an empty profile on first launch |
+
+Per-worktree profiles live in `orca-dev-wt/` next to `orca-dev` (`~/Library/Application
+Support` on macOS, `%APPDATA%` on Windows, `$XDG_CONFIG_HOME` or `~/.config` on Linux). The
+automatic storage cleanup removes them once their worktree is gone and nothing still runs from
+them. `config/scripts/orca-dev.mjs` and the `orca-dev` terminal wrapper target the profile this
+checkout's `pnpm dev` used.
+
+Seeding copies a slim set of state from another profile: projects, workspaces, settings, SSH
+targets, and the Orca account session. Terminal sessions, PTY leases, automations, caches, logs,
+and lock files are not copied, and the source profile is only read. The default source is
+`orca-dev`, or the installed app's profile when `orca-dev` is empty. Seed from `orca-dev` when you
+need the signed-in account session: dev builds cannot decrypt secrets the installed app encrypted.
+
+```bash
+pnpm dev:seed-profile [--from=] [--to=] [--reset]
+pnpm dev:fresh [--keep] [--seed-from[=]] # temporary profile, removed on exit
+```
+
### Targeting Local Tests
Use a test file or directory when you know the relevant suites. For dependency-based
diff --git a/config/build-plugins/dev-serve-config.ts b/config/build-plugins/dev-serve-config.ts
new file mode 100644
index 00000000000..3d8a4618b35
--- /dev/null
+++ b/config/build-plugins/dev-serve-config.ts
@@ -0,0 +1,28 @@
+import type { UserConfig } from 'electron-vite'
+
+/** Development-server overrides; Electron Vite also uses Vite's build command for the dev main process. */
+export function createDevServeConfig(
+ config: UserConfig,
+ env: NodeJS.ProcessEnv = process.env
+): UserConfig {
+ // run-electron-vite-dev.mjs probes a per-worktree port and reports it; strict keeps that report true.
+ const rendererPort = Number.parseInt(env.ORCA_DEV_RENDERER_PORT ?? '', 10)
+ return {
+ ...config,
+ renderer:
+ Number.isInteger(rendererPort) && config.renderer
+ ? {
+ ...config.renderer,
+ server: { ...config.renderer.server, port: rendererPort, strictPort: true }
+ }
+ : config.renderer,
+ main: {
+ ...config.main,
+ build: {
+ ...config.main?.build,
+ minify: false,
+ sourcemap: env.ORCA_DEV_MAIN_SOURCEMAPS === '0' ? false : config.main?.build?.sourcemap
+ }
+ }
+ }
+}
diff --git a/config/scripts/computer-use-smoke.mjs b/config/scripts/computer-use-smoke.mjs
index f71717680bd..85c1c9e8219 100644
--- a/config/scripts/computer-use-smoke.mjs
+++ b/config/scripts/computer-use-smoke.mjs
@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs'
-import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
+import { resolveDevCliUserDataPath } from './dev-instance-isolation.mjs'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const cliPath =
@@ -148,7 +148,8 @@ function runCli(cliArgs, options = {}) {
env: {
...process.env,
ORCA_USER_DATA_PATH:
- process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
+ process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ??
+ resolveDevCliUserDataPath({ repoRoot, env: { ...process.env } })
}
})
if (child.status !== 0) {
@@ -168,16 +169,6 @@ function runCli(cliArgs, options = {}) {
}
}
-function defaultDevUserDataPath() {
- if (process.platform === 'darwin') {
- return resolve(homedir(), 'Library', 'Application Support', 'orca-dev')
- }
- if (process.platform === 'win32') {
- return resolve(process.env.APPDATA ?? resolve(homedir(), 'AppData', 'Roaming'), 'orca-dev')
- }
- return resolve(process.env.XDG_CONFIG_HOME ?? resolve(homedir(), '.config'), 'orca-dev')
-}
-
function unwrapResult(value) {
if (value && typeof value === 'object' && 'result' in value) {
return value.result
diff --git a/config/scripts/dev-fresh-profile.mjs b/config/scripts/dev-fresh-profile.mjs
new file mode 100644
index 00000000000..5bdf080e9eb
--- /dev/null
+++ b/config/scripts/dev-fresh-profile.mjs
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+// Launch `pnpm dev` on a fresh, isolated userData profile so the app behaves like a first-time
+// install (onboarding paints, no persisted repos or sessions). Other dev profiles stay untouched.
+import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { spawnProcess } from '@orca/process-host'
+import { getDefaultSeedSource, seedDevProfile } from './dev-profile-seed.mjs'
+
+const USAGE = `Usage: pnpm dev:fresh [--keep] [--seed-from[=]] [-- ]
+
+ --keep keep the temporary profile after exit
+ --seed-from[=] start from a slim copy of a profile (projects, workspaces, settings,
+ SSH targets, account session); default: orca-dev, else the installed app
+ ORCA_FRESH_PROFILE_DIR= use (and keep) a fixed profile directory`
+
+const TEMP_PREFIX = 'orca-fresh-profile-'
+
+function parseArgs(argv) {
+ const options = { keep: false, seedFrom: null, devArgs: [] }
+ for (let index = 0; index < argv.length; index++) {
+ const arg = argv[index]
+ if (arg === '--') {
+ options.devArgs = argv.slice(index + 1)
+ break
+ }
+ if (arg === '--keep') {
+ options.keep = true
+ } else if (arg === '--seed-from') {
+ options.seedFrom = getDefaultSeedSource()
+ } else if (arg.startsWith('--seed-from=')) {
+ options.seedFrom = path.resolve(arg.slice('--seed-from='.length))
+ } else if (arg === '-h' || arg === '--help') {
+ options.help = true
+ } else {
+ throw new Error(`Unknown argument: ${arg}\n${USAGE}`)
+ }
+ }
+ return options
+}
+
+const options = parseArgs(process.argv.slice(2))
+if (options.help) {
+ console.log(USAGE)
+ process.exit(0)
+}
+
+const fixedDir = process.env.ORCA_FRESH_PROFILE_DIR
+const profileDir = fixedDir ? path.resolve(fixedDir) : mkdtempSync(path.join(tmpdir(), TEMP_PREFIX))
+mkdirSync(profileDir, { recursive: true })
+
+function cleanup() {
+ const removable = !options.keep && !fixedDir && path.basename(profileDir).startsWith(TEMP_PREFIX)
+ if (removable && existsSync(profileDir)) {
+ rmSync(profileDir, { recursive: true, force: true })
+ console.error(`[dev-fresh-profile] removed ${profileDir}`)
+ } else {
+ console.error(`[dev-fresh-profile] kept ${profileDir}`)
+ }
+}
+
+try {
+ if (options.seedFrom) {
+ await seedDevProfile({ sourceDir: options.seedFrom, targetDir: profileDir })
+ }
+} catch (error) {
+ console.error(`[dev-fresh-profile] ${error.message}`)
+ cleanup()
+ process.exit(1)
+}
+
+console.error(`[dev-fresh-profile] using userData=${profileDir}`)
+const child = spawnProcess({
+ program: 'pnpm',
+ args: ['dev', ...options.devArgs],
+ env: {
+ ...process.env,
+ ORCA_DEV_USER_DATA_PATH: profileDir,
+ // Why: seeded profiles already finished onboarding; only empty ones should show first-run education.
+ ...(options.seedFrom ? {} : { ORCA_DEV_SHOW_FIRST_RUN_EDUCATION: '1' })
+ },
+ stdio: 'inherit'
+})
+// Why: Ctrl+C reaches pnpm through the shared terminal; wait for it so cleanup sees a closed profile.
+process.on('SIGINT', () => {})
+process.on('SIGTERM', () => child.kill('SIGTERM'))
+child.on('exit', (code, signal) => {
+ cleanup()
+ process.exit(code ?? (signal ? 1 : 0))
+})
+child.on('error', (error) => {
+ console.error(`[dev-fresh-profile] ${error.message}`)
+ cleanup()
+ process.exit(1)
+})
diff --git a/config/scripts/dev-fresh-profile.sh b/config/scripts/dev-fresh-profile.sh
deleted file mode 100755
index 7b8041477de..00000000000
--- a/config/scripts/dev-fresh-profile.sh
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/usr/bin/env bash
-# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
-# like a first-time install (onboarding overlay paints, no persisted repos,
-# no saved sessions). Your real `orca-dev` profile is left untouched.
-#
-# Usage:
-# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
-# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
-# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
-set -euo pipefail
-
-KEEP=0
-for arg in "$@"; do
- case "$arg" in
- --keep) KEEP=1 ;;
- -h|--help)
- sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
- exit 0 ;;
- *)
- echo "Unknown argument: $arg" >&2
- echo "Usage: $0 [--keep] [--help]" >&2
- exit 2 ;;
- esac
-done
-
-PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
-mkdir -p "$PROFILE_DIR"
-
-cleanup() {
- if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
- # Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
- [[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
- rm -rf "$PROFILE_DIR"
- echo "[dev-fresh-profile] removed $PROFILE_DIR"
- else
- echo "[dev-fresh-profile] kept $PROFILE_DIR"
- fi
-}
-trap cleanup EXIT
-
-echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
-# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
-ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" ORCA_DEV_SHOW_FIRST_RUN_EDUCATION=1 pnpm dev
diff --git a/config/scripts/dev-instance-isolation.mjs b/config/scripts/dev-instance-isolation.mjs
new file mode 100644
index 00000000000..77ef1eded73
--- /dev/null
+++ b/config/scripts/dev-instance-isolation.mjs
@@ -0,0 +1,310 @@
+import { createHash } from 'node:crypto'
+import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import net from 'node:net'
+import path from 'node:path'
+
+export const SHARED_DEV_PROFILE_DIR_NAME = 'orca-dev'
+// Why the "orca-dev" prefix: CLI/orchestration dev-mode checks match "orca-dev" in ORCA_USER_DATA_PATH.
+// Why short: runtime sockets live in userData and macOS caps socket paths at 104 bytes.
+export const WORKTREE_DEV_PROFILES_DIR_NAME = 'orca-dev-wt'
+export const WORKTREE_PROFILE_MARKER_FILE = 'orca-dev-worktree.json'
+export const WORKTREE_PROFILE_NAME_PATTERN = /^[\da-f]{10}$/
+export const DEV_PROFILE_MODES = ['shared', 'worktree']
+
+// Why these ranges: clear of Vite's 5173+ (common in projects opened inside dev Orca),
+// Chrome's 9222, and Orca's production ports (pairing WebSocket 6768).
+export const RENDERER_PORT_RANGE = { base: 15200, spread: 200 }
+export const CDP_PORT_RANGE = { base: 9333, spread: 200 }
+const PORT_SWEEP = 64
+
+// Values the runner derives per worktree; a dev Orca exports them to every terminal it spawns.
+const RUNNER_DERIVED_ENV_KEYS = [
+ 'ORCA_DEV_REPO_ROOT',
+ 'ORCA_DEV_INSTANCE_KEY',
+ 'ORCA_DEV_BRANCH',
+ 'ORCA_DEV_WORKTREE_NAME',
+ 'ORCA_DEV_INSTANCE_LABEL',
+ 'ORCA_DEV_DOCK_TITLE',
+ 'ORCA_DEV_RENDERER_PORT'
+]
+
+export function getAppDataDir(env = process.env, platform = process.platform) {
+ if (platform === 'darwin') {
+ return path.join(env.HOME ?? '', 'Library', 'Application Support')
+ }
+ if (platform === 'win32') {
+ return env.APPDATA ?? path.join(env.USERPROFILE ?? '', 'AppData', 'Roaming')
+ }
+ return env.XDG_CONFIG_HOME || path.join(env.HOME ?? '', '.config')
+}
+
+export function getSharedDevUserDataPath(env = process.env, platform = process.platform) {
+ return path.join(getAppDataDir(env, platform), SHARED_DEV_PROFILE_DIR_NAME)
+}
+
+export function getWorktreeDevProfilesRoot(env = process.env, platform = process.platform) {
+ return path.join(getAppDataDir(env, platform), WORKTREE_DEV_PROFILES_DIR_NAME)
+}
+
+export function getWorktreeDevUserDataPath(
+ instanceKey,
+ env = process.env,
+ platform = process.platform
+) {
+ const name = createHash('sha1').update(instanceKey).digest('hex').slice(0, 10)
+ return path.join(getWorktreeDevProfilesRoot(env, platform), name)
+}
+
+export function sameFilesystemPath(left, right, platform = process.platform) {
+ const a = path.resolve(left)
+ const b = path.resolve(right)
+ return platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
+}
+
+function isLinkedWorktree(repoRoot) {
+ try {
+ // Linked worktrees have a `.git` file pointing at the main checkout's git dir.
+ return lstatSync(path.join(repoRoot, '.git')).isFile()
+ } catch {
+ return false
+ }
+}
+
+/**
+ * A dev Orca's terminals inherit the runner's derived env. A `pnpm dev` started from one of those
+ * terminals in another worktree must derive its own identity instead of reusing the parent's.
+ */
+export function dropInheritedDevInstanceEnv(env, repoRoot) {
+ const inheritedRoot = env.ORCA_DEV_REPO_ROOT
+ if (!inheritedRoot || sameFilesystemPath(inheritedRoot, repoRoot)) {
+ return
+ }
+ // An explicit override differs from the parent app's own userData; an inherited one matches it.
+ if (env.ORCA_DEV_USER_DATA_PATH && env.ORCA_DEV_USER_DATA_PATH === env.ORCA_USER_DATA_PATH) {
+ delete env.ORCA_DEV_USER_DATA_PATH
+ }
+ for (const key of RUNNER_DERIVED_ENV_KEYS) {
+ delete env[key]
+ }
+}
+
+/** Precedence: ORCA_DEV_USER_DATA_PATH, then --shared/--worktree-profile or ORCA_DEV_PROFILE, then checkout type. */
+export function resolveDevProfile({
+ repoRoot,
+ instanceKey = repoRoot,
+ mode,
+ env = process.env,
+ platform = process.platform
+}) {
+ if (env.ORCA_DEV_USER_DATA_PATH) {
+ return { kind: 'custom', userDataPath: path.resolve(env.ORCA_DEV_USER_DATA_PATH) }
+ }
+ const requested = mode ?? (env.ORCA_DEV_PROFILE || undefined)
+ if (requested !== undefined && !DEV_PROFILE_MODES.includes(requested)) {
+ throw new Error(
+ `ORCA_DEV_PROFILE must be one of ${DEV_PROFILE_MODES.join(', ')} (got ${JSON.stringify(requested)})`
+ )
+ }
+ // Why the main checkout keeps orca-dev: it is the long-lived profile developers already use.
+ const kind = requested ?? (isLinkedWorktree(repoRoot) ? 'worktree' : 'shared')
+ return kind === 'shared'
+ ? { kind, userDataPath: getSharedDevUserDataPath(env, platform) }
+ : { kind, userDataPath: getWorktreeDevUserDataPath(instanceKey, env, platform) }
+}
+
+export function writeWorktreeProfileMarker(userDataPath, { repoRoot, instanceKey, gitCommonDir }) {
+ mkdirSync(userDataPath, { recursive: true, mode: 0o700 })
+ const markerPath = path.join(userDataPath, WORKTREE_PROFILE_MARKER_FILE)
+ const marker = { version: 1, repoRoot, instanceKey, gitCommonDir: gitCommonDir ?? null }
+ const serialized = `${JSON.stringify(marker, null, 2)}\n`
+ try {
+ if (readFileSync(markerPath, 'utf8') === serialized) {
+ return
+ }
+ } catch {}
+ writeFileSync(markerPath, serialized, { mode: 0o600 })
+}
+
+export function readWorktreeProfileMarker(userDataPath) {
+ try {
+ const marker = JSON.parse(
+ readFileSync(path.join(userDataPath, WORKTREE_PROFILE_MARKER_FILE), 'utf8')
+ )
+ return marker?.version === 1 && typeof marker.repoRoot === 'string' ? marker : null
+ } catch {
+ return null
+ }
+}
+
+export function getDevInstanceRecordPath(repoRoot) {
+ return path.join(repoRoot, 'out', 'dev-instance.json')
+}
+
+export function writeDevInstanceRecord(repoRoot, record) {
+ const recordPath = getDevInstanceRecordPath(repoRoot)
+ mkdirSync(path.dirname(recordPath), { recursive: true })
+ writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`)
+}
+
+export function readDevInstanceRecord(repoRoot) {
+ try {
+ const record = JSON.parse(readFileSync(getDevInstanceRecordPath(repoRoot), 'utf8'))
+ return typeof record?.userData === 'string' ? record : null
+ } catch {
+ return null
+ }
+}
+
+/** The CLI follows the profile its worktree's dev app last used, so `--shared-profile` runs stay reachable. */
+export function resolveDevCliUserDataPath({
+ repoRoot,
+ env = process.env,
+ platform = process.platform
+}) {
+ dropInheritedDevInstanceEnv(env, repoRoot)
+ if (env.ORCA_DEV_USER_DATA_PATH || env.ORCA_DEV_PROFILE) {
+ return resolveDevProfile({ repoRoot, instanceKey: env.ORCA_DEV_INSTANCE_KEY, env, platform })
+ .userDataPath
+ }
+ const record = readDevInstanceRecord(repoRoot)
+ // Custom profiles are often temporary; they keep requiring an explicit ORCA_DEV_USER_DATA_PATH.
+ if (record && record.profile !== 'custom' && existsSync(record.userData)) {
+ return record.userData
+ }
+ return resolveDevProfile({ repoRoot, instanceKey: env.ORCA_DEV_INSTANCE_KEY, env, platform })
+ .userDataPath
+}
+
+function canListen(port, host) {
+ return new Promise((resolve) => {
+ const server = net.createServer()
+ server.once('error', (error) => {
+ try {
+ server.close()
+ } catch {}
+ // Hosts without IPv6 cannot have a conflicting ::1 listener.
+ resolve(host === '::1' && ['EADDRNOTAVAIL', 'EAFNOSUPPORT'].includes(error?.code))
+ })
+ server.once('listening', () => server.close(() => resolve(true)))
+ server.listen({ port, host, exclusive: true })
+ })
+}
+
+// Why both loopbacks: Vite binds `localhost`, which resolves to ::1 first on some hosts.
+export async function isPortFree(port) {
+ return (await canListen(port, '127.0.0.1')) && (await canListen(port, '::1'))
+}
+
+/** Deterministic per key so restarts reuse a port; sweeps forward when it is taken. */
+export async function pickInstancePort(seedKey, { base, spread }, isFree = isPortFree) {
+ const seed = Number.parseInt(createHash('sha1').update(seedKey).digest('hex').slice(0, 8), 16)
+ const start = base + (seed % spread)
+ for (let offset = 0; offset < PORT_SWEEP; offset++) {
+ if (await isFree(start + offset)) {
+ return start + offset
+ }
+ }
+ return null
+}
+
+export function parsePortValue(raw) {
+ const value = Number.parseInt(raw, 10)
+ if (!Number.isInteger(value) || value < 1 || value > 65535 || String(value) !== raw.trim()) {
+ return null
+ }
+ return value
+}
+
+async function resolveEnvOrPickedPort(env, envName, seedKey, range) {
+ const raw = env[envName]
+ if (raw) {
+ const port = parsePortValue(raw)
+ if (port !== null) {
+ return port
+ }
+ console.error(
+ `[orca-dev] Ignoring invalid ${envName}=${JSON.stringify(raw)}; falling back to probe.`
+ )
+ }
+ return pickInstancePort(seedKey, range)
+}
+
+function readUserPassedDebugPort(forwardedArgs) {
+ for (let index = 0; index < forwardedArgs.length; index++) {
+ const arg = forwardedArgs[index]
+ if (arg.startsWith('--remote-debugging-port=')) {
+ return parsePortValue(arg.slice('--remote-debugging-port='.length))
+ }
+ if (arg === '--remote-debugging-port') {
+ return parsePortValue(forwardedArgs[index + 1] ?? '')
+ }
+ }
+ return null
+}
+
+/**
+ * Why: every `pnpm dev` should be attachable from agent-browser/playwright-cli without manual
+ * port juggling. Exports ORCA_DEV_RENDERER_PORT for electron.vite.config.ts.
+ */
+export async function resolveDevInstancePorts({ forwardedArgs, instanceKey, env = process.env }) {
+ // Why: exact match (or `=` form) avoids false positives on `--remote-debugging-port-*` flags;
+ // `--remote-debugging-pipe` opts into pipe-based debugging, so no port is injected.
+ const userPassedPort = forwardedArgs.some(
+ (arg) =>
+ arg === '--remote-debugging-port' ||
+ arg.startsWith('--remote-debugging-port=') ||
+ arg === '--remote-debugging-pipe'
+ )
+ let cdpPort = readUserPassedDebugPort(forwardedArgs)
+ let forwardedExtras = []
+ if (!userPassedPort) {
+ cdpPort = await resolveEnvOrPickedPort(
+ env,
+ 'REMOTE_DEBUGGING_PORT',
+ instanceKey,
+ CDP_PORT_RANGE
+ )
+ if (cdpPort !== null) {
+ forwardedExtras = [`--remote-debugging-port=${cdpPort}`]
+ } else {
+ console.error(
+ '[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.'
+ )
+ }
+ }
+ const rendererPort = await resolveEnvOrPickedPort(
+ env,
+ 'ORCA_DEV_RENDERER_PORT',
+ `renderer:${instanceKey}`,
+ RENDERER_PORT_RANGE
+ )
+ if (rendererPort !== null) {
+ env.ORCA_DEV_RENDERER_PORT = String(rendererPort)
+ } else {
+ delete env.ORCA_DEV_RENDERER_PORT
+ }
+ return { cdpPort, rendererPort, forwardedExtras }
+}
+
+/** Prints one `ORCA_DEV_INSTANCE {json}` stdout line and mirrors it to out/dev-instance.json. */
+export function announceDevInstance({ repoRoot, profile, cdpPort, rendererPort }) {
+ // Why 127.0.0.1: matches the interface probed (localhost may resolve to ::1 on IPv6-first hosts).
+ const instance = {
+ userData: profile.userDataPath,
+ profile: profile.kind,
+ rendererPort,
+ rendererUrl: rendererPort === null ? null : `http://localhost:${rendererPort}`,
+ cdpPort,
+ cdpUrl: cdpPort === null ? null : `http://127.0.0.1:${cdpPort}`,
+ repoRoot,
+ runnerPid: process.pid
+ }
+ if (cdpPort !== null) {
+ console.error(`[orca-dev] Remote debugging on ${instance.cdpUrl}`)
+ }
+ console.error(`[orca-dev] Profile (${profile.kind}): ${profile.userDataPath}`)
+ writeDevInstanceRecord(repoRoot, instance)
+ // Why stdout: agents grep `ORCA_DEV_INSTANCE ` to find the profile and CDP endpoint.
+ console.log(`ORCA_DEV_INSTANCE ${JSON.stringify(instance)}`)
+ return instance
+}
diff --git a/config/scripts/dev-instance-isolation.test.mjs b/config/scripts/dev-instance-isolation.test.mjs
new file mode 100644
index 00000000000..fde4ae8821c
--- /dev/null
+++ b/config/scripts/dev-instance-isolation.test.mjs
@@ -0,0 +1,106 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ dropInheritedDevInstanceEnv,
+ pickInstancePort,
+ resolveDevProfile
+} from './dev-instance-isolation.mjs'
+
+const roots = []
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+function checkout(kind) {
+ const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-isolation-'))
+ roots.push(root)
+ if (kind === 'linked') {
+ writeFileSync(path.join(root, '.git'), 'gitdir: /repo/.git/worktrees/feature\n')
+ } else {
+ mkdirSync(path.join(root, '.git'))
+ }
+ return root
+}
+
+const env = { HOME: '/home/dev', XDG_CONFIG_HOME: '/home/dev/.config' }
+
+describe('resolveDevProfile', () => {
+ it('isolates linked worktrees from each other and keeps orca-dev for the main checkout', () => {
+ const first = resolveDevProfile({ repoRoot: checkout('linked'), env, platform: 'linux' })
+ const second = resolveDevProfile({ repoRoot: checkout('linked'), env, platform: 'linux' })
+ const main = resolveDevProfile({ repoRoot: checkout('main'), env, platform: 'linux' })
+
+ expect(first.kind).toBe('worktree')
+ expect(path.dirname(first.userDataPath)).toBe(path.join('/home/dev/.config', 'orca-dev-wt'))
+ expect(first.userDataPath).not.toBe(second.userDataPath)
+ expect(main).toEqual({
+ kind: 'shared',
+ userDataPath: path.join('/home/dev/.config', 'orca-dev')
+ })
+ })
+
+ it('lets the flag, ORCA_DEV_PROFILE, and an explicit path override the default', () => {
+ const repoRoot = checkout('linked')
+ expect(resolveDevProfile({ repoRoot, mode: 'shared', env, platform: 'linux' }).kind).toBe(
+ 'shared'
+ )
+ expect(
+ resolveDevProfile({
+ repoRoot: checkout('main'),
+ env: { ...env, ORCA_DEV_PROFILE: 'worktree' },
+ platform: 'linux'
+ }).kind
+ ).toBe('worktree')
+ expect(
+ resolveDevProfile({
+ repoRoot,
+ mode: 'shared',
+ env: { ...env, ORCA_DEV_USER_DATA_PATH: '/tmp/custom' },
+ platform: 'linux'
+ })
+ ).toEqual({ kind: 'custom', userDataPath: path.resolve('/tmp/custom') })
+ expect(() =>
+ resolveDevProfile({ repoRoot, env: { ...env, ORCA_DEV_PROFILE: 'other' }, platform: 'linux' })
+ ).toThrow('ORCA_DEV_PROFILE')
+ })
+})
+
+describe('dropInheritedDevInstanceEnv', () => {
+ it('drops a parent dev instance identity but keeps an explicit profile override', () => {
+ const inherited = {
+ ORCA_DEV_REPO_ROOT: '/worktrees/parent',
+ ORCA_DEV_INSTANCE_KEY: '/worktrees/parent',
+ ORCA_DEV_RENDERER_PORT: '15300',
+ ORCA_USER_DATA_PATH: '/profiles/parent',
+ ORCA_DEV_USER_DATA_PATH: '/profiles/parent'
+ }
+ dropInheritedDevInstanceEnv(inherited, '/worktrees/child')
+ expect(inherited).toEqual({ ORCA_USER_DATA_PATH: '/profiles/parent' })
+
+ const explicit = { ...inherited, ORCA_DEV_USER_DATA_PATH: '/profiles/mine' }
+ dropInheritedDevInstanceEnv(explicit, '/worktrees/child')
+ expect(explicit.ORCA_DEV_USER_DATA_PATH).toBe('/profiles/mine')
+
+ const same = { ...inherited }
+ dropInheritedDevInstanceEnv(same, '/worktrees/parent')
+ expect(same).toEqual(inherited)
+ })
+})
+
+describe('pickInstancePort', () => {
+ it('is stable per key and sweeps forward past taken ports', async () => {
+ const range = { base: 15200, spread: 200 }
+ const first = await pickInstancePort('worktree-a', range, async () => true)
+ expect(await pickInstancePort('worktree-a', range, async () => true)).toBe(first)
+ expect(first).toBeGreaterThanOrEqual(15200)
+ expect(first).toBeLessThan(15400)
+ expect(await pickInstancePort('worktree-a', range, async (port) => port > first + 1)).toBe(
+ first + 2
+ )
+ expect(await pickInstancePort('worktree-a', range, async () => false)).toBeNull()
+ })
+})
diff --git a/config/scripts/dev-profile-seed.mjs b/config/scripts/dev-profile-seed.mjs
new file mode 100644
index 00000000000..0902d96899a
--- /dev/null
+++ b/config/scripts/dev-profile-seed.mjs
@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+// Seeds an empty dev profile with a slim copy of another Orca profile's durable configuration.
+import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import {
+ getAppDataDir,
+ getSharedDevUserDataPath,
+ getWorktreeDevProfilesRoot,
+ readDevInstanceRecord,
+ resolveDevProfile,
+ sameFilesystemPath
+} from './dev-instance-isolation.mjs'
+import { isProcessAlive } from './remote-agent-session-process-cleanup.mjs'
+
+// Allowlist of profile-state domains: projects, workspaces, settings, and SSH targets.
+// Terminal sessions, PTY leases, automations (they would run twice), and telemetry stay behind.
+export const SEEDED_PROFILE_STATE_DOMAINS = [
+ 'schemaVersion',
+ 'repos',
+ 'projects',
+ 'projectHostSetups',
+ 'projectGroups',
+ 'folderWorkspaces',
+ 'sparsePresetsByRepo',
+ 'worktreeMeta',
+ 'worktreeMetaByIdentity',
+ 'worktreeIdentityAliases',
+ 'worktreeLineageById',
+ 'workspaceLineageByChildKey',
+ 'settings',
+ 'ui',
+ 'onboarding',
+ 'sshTargets',
+ 'sshTargetGenerationCounter',
+ 'deletedSshConfigAliases',
+ 'removedSshTargetTombstones'
+]
+// Orca account session (encrypted with the app's safeStorage key).
+const SEEDED_PROFILE_FILES = ['account-session.json.enc']
+// Why Local State: on Windows it holds the DPAPI-wrapped key that decrypts safeStorage secrets.
+const SEEDED_ROOT_FILES = ['Local State']
+const PROFILE_INDEX_FILE = 'orca-profile-index.json'
+const LEGACY_DATA_FILE = 'orca-data.json'
+const PROFILE_STATE_DATABASE_FILE = 'profile-state.db'
+const PROFILE_ID_PATTERN = /^[A-Za-z0-9._-]+$/
+export const SEED_RECEIPT_FILE = 'orca-dev-seed.json'
+
+export function profileHasState(userDataPath) {
+ return (
+ existsSync(path.join(userDataPath, PROFILE_INDEX_FILE)) ||
+ existsSync(path.join(userDataPath, 'profiles')) ||
+ existsSync(path.join(userDataPath, LEGACY_DATA_FILE))
+ )
+}
+
+/** Prefer orca-dev: dev builds share its safeStorage key, so its account session stays decryptable. */
+export function getDefaultSeedSource(env = process.env, platform = process.platform) {
+ const devProfile = getSharedDevUserDataPath(env, platform)
+ if (profileHasState(devProfile)) {
+ return devProfile
+ }
+ return path.join(getAppDataDir(env, platform), 'orca')
+}
+
+async function readProfileStateDatabaseDomains(databasePath) {
+ const { DatabaseSync } = await import('node:sqlite')
+ // Why readOnly: the source may belong to a running Orca; a reader never writes its pages.
+ const db = new DatabaseSync(databasePath, { readOnly: true })
+ try {
+ const domains = {}
+ for (const row of db.prepare('SELECT domain, payload FROM profile_state_documents').all()) {
+ domains[row.domain] = JSON.parse(row.payload)
+ }
+ return domains
+ } finally {
+ db.close()
+ }
+}
+
+async function readProfileDomains(profileDir) {
+ const databasePath = path.join(profileDir, PROFILE_STATE_DATABASE_FILE)
+ if (existsSync(databasePath)) {
+ return readProfileStateDatabaseDomains(databasePath)
+ }
+ const legacyPath = path.join(profileDir, LEGACY_DATA_FILE)
+ return existsSync(legacyPath) ? JSON.parse(readFileSync(legacyPath, 'utf8')) : null
+}
+
+function pickSeededDomains(domains) {
+ const picked = {}
+ for (const domain of SEEDED_PROFILE_STATE_DOMAINS) {
+ if (domains[domain] !== undefined) {
+ picked[domain] = domains[domain]
+ }
+ }
+ return picked
+}
+
+function writePrivateJson(filePath, value) {
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 })
+ writeFileSync(filePath, JSON.stringify(value), { mode: 0o600 })
+}
+
+function copyIfPresent(source, target) {
+ if (!existsSync(source)) {
+ return false
+ }
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
+ copyFileSync(source, target)
+ return true
+}
+
+function isInside(parent, child) {
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
+}
+
+/**
+ * Orca imports a profile's legacy orca-data.json into SQLite on first launch, so the seed writes
+ * that JSON instead of copying live databases. Never writes to sourceDir.
+ */
+export async function seedDevProfile({ sourceDir, targetDir, report = console.error }) {
+ if (!profileHasState(sourceDir)) {
+ throw new Error(`Seed source has no Orca profile state: ${sourceDir}`)
+ }
+ if (isInside(sourceDir, targetDir) || isInside(targetDir, sourceDir)) {
+ throw new Error('Seed source and target must be separate directories')
+ }
+ if (profileHasState(targetDir)) {
+ throw new Error(`Target profile already has state: ${targetDir}`)
+ }
+ mkdirSync(targetDir, { recursive: true, mode: 0o700 })
+
+ const seededProfiles = []
+ const indexPath = path.join(sourceDir, PROFILE_INDEX_FILE)
+ if (existsSync(indexPath)) {
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'))
+ const profileIds = (Array.isArray(index?.profiles) ? index.profiles : [])
+ .map((profile) => profile?.id)
+ .filter((id) => typeof id === 'string' && PROFILE_ID_PATTERN.test(id))
+ for (const profileId of profileIds) {
+ const sourceProfileDir = path.join(sourceDir, 'profiles', profileId)
+ const targetProfileDir = path.join(targetDir, 'profiles', profileId)
+ const domains = await readProfileDomains(sourceProfileDir)
+ if (domains) {
+ writePrivateJson(path.join(targetProfileDir, LEGACY_DATA_FILE), pickSeededDomains(domains))
+ }
+ for (const file of SEEDED_PROFILE_FILES) {
+ copyIfPresent(path.join(sourceProfileDir, file), path.join(targetProfileDir, file))
+ }
+ seededProfiles.push(profileId)
+ }
+ writePrivateJson(path.join(targetDir, PROFILE_INDEX_FILE), index)
+ } else {
+ // Pre-profile layout: Orca migrates a root orca-data.json itself.
+ const domains = JSON.parse(readFileSync(path.join(sourceDir, LEGACY_DATA_FILE), 'utf8'))
+ writePrivateJson(path.join(targetDir, LEGACY_DATA_FILE), pickSeededDomains(domains))
+ }
+ for (const file of SEEDED_ROOT_FILES) {
+ copyIfPresent(path.join(sourceDir, file), path.join(targetDir, file))
+ }
+ writePrivateJson(path.join(targetDir, SEED_RECEIPT_FILE), {
+ version: 1,
+ sourceDir: path.resolve(sourceDir),
+ seededAt: new Date().toISOString(),
+ profiles: seededProfiles
+ })
+ report(
+ `[orca-dev] Seeded ${targetDir} from ${sourceDir} (${seededProfiles.length || 'legacy'} profile(s))`
+ )
+}
+
+function parseArgs(argv) {
+ const options = { reset: false }
+ for (const arg of argv) {
+ if (arg === '--reset') {
+ options.reset = true
+ } else if (arg === '--help' || arg === '-h') {
+ options.help = true
+ } else if (arg.startsWith('--from=')) {
+ options.from = arg.slice('--from='.length)
+ } else if (arg.startsWith('--to=')) {
+ options.to = arg.slice('--to='.length)
+ } else {
+ throw new Error(`Unknown argument: ${arg}`)
+ }
+ }
+ return options
+}
+
+function assertResettable(targetDir, repoRoot) {
+ const underWorktreeProfiles = isInside(getWorktreeDevProfilesRoot(), targetDir)
+ if (!underWorktreeProfiles || sameFilesystemPath(getWorktreeDevProfilesRoot(), targetDir)) {
+ throw new Error(`--reset only removes per-worktree dev profiles; delete ${targetDir} yourself`)
+ }
+ const record = readDevInstanceRecord(repoRoot)
+ if (
+ record &&
+ sameFilesystemPath(record.userData, targetDir) &&
+ Number.isInteger(record.runnerPid) &&
+ isProcessAlive(record.runnerPid)
+ ) {
+ throw new Error('Stop this worktree’s `pnpm dev` before resetting its profile')
+ }
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2))
+ if (options.help) {
+ console.log(
+ [
+ 'Usage: pnpm dev:seed-profile [--from=] [--to=] [--reset]',
+ '',
+ 'Copies projects, workspaces, settings, SSH targets, and the Orca account session into an empty',
+ "dev profile. Default --from: orca-dev, else the installed app's profile. Default --to: this",
+ "worktree's `pnpm dev` profile. --reset first deletes an existing per-worktree profile."
+ ].join('\n')
+ )
+ return
+ }
+ const repoRoot = path.resolve(import.meta.dirname, '../..')
+ const targetDir = path.resolve(
+ options.to ??
+ resolveDevProfile({ repoRoot, instanceKey: process.env.ORCA_DEV_INSTANCE_KEY }).userDataPath
+ )
+ const sourceDir = path.resolve(options.from ?? getDefaultSeedSource())
+ if (options.reset && existsSync(targetDir)) {
+ assertResettable(targetDir, repoRoot)
+ rmSync(targetDir, { recursive: true, force: true })
+ }
+ await seedDevProfile({ sourceDir, targetDir })
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
+ main().catch((error) => {
+ console.error(`[orca-dev] ${error.message}`)
+ process.exitCode = 1
+ })
+}
diff --git a/config/scripts/dev-profile-seed.test.mjs b/config/scripts/dev-profile-seed.test.mjs
new file mode 100644
index 00000000000..35e5812400b
--- /dev/null
+++ b/config/scripts/dev-profile-seed.test.mjs
@@ -0,0 +1,103 @@
+import { createHash } from 'node:crypto'
+import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { DatabaseSync } from 'node:sqlite'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { seedDevProfile } from './dev-profile-seed.mjs'
+
+const roots = []
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+function hashTree(dir) {
+ const hash = createHash('sha256')
+ for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
+ if (entry.isFile()) {
+ const file = path.join(entry.parentPath, entry.name)
+ hash.update(path.relative(dir, file)).update(readFileSync(file))
+ }
+ }
+ return hash.digest('hex')
+}
+
+function createSourceProfile() {
+ const root = mkdtempSync(path.join(tmpdir(), 'orca-seed-source-'))
+ roots.push(root)
+ const profileDir = path.join(root, 'profiles', 'local-default')
+ mkdirSync(profileDir, { recursive: true })
+ writeFileSync(
+ path.join(root, 'orca-profile-index.json'),
+ JSON.stringify({
+ schemaVersion: 1,
+ activeProfileId: 'local-default',
+ profiles: [{ id: 'local-default' }]
+ })
+ )
+ writeFileSync(path.join(profileDir, 'account-session.json.enc'), '{"ciphertext":"x"}')
+ writeFileSync(path.join(root, 'SingletonLock'), 'locked')
+ mkdirSync(path.join(root, 'Code Cache'))
+ writeFileSync(path.join(root, 'Code Cache', 'blob'), 'cache')
+ const db = new DatabaseSync(path.join(profileDir, 'profile-state.db'))
+ db.exec(
+ 'CREATE TABLE profile_state_documents (domain TEXT PRIMARY KEY, payload TEXT NOT NULL, domain_version INTEGER, revision INTEGER, updated_at INTEGER, content_hash TEXT)'
+ )
+ const insert = db.prepare("INSERT INTO profile_state_documents VALUES (?, ?, 1, 1, 0, 'hash')")
+ insert.run('repos', JSON.stringify([{ id: 'repo-1', path: '/src/app' }]))
+ insert.run('settings', JSON.stringify({ theme: 'dark' }))
+ insert.run('sshTargets', JSON.stringify([{ id: 'ssh-1' }]))
+ insert.run('workspaceSession', JSON.stringify({ tabsByWorktree: { a: [] } }))
+ insert.run('sshRemotePtyLeases', JSON.stringify([{ ptyId: 'p' }]))
+ insert.run('automations', JSON.stringify([{ id: 'nightly' }]))
+ db.close()
+ return root
+}
+
+describe('seedDevProfile', () => {
+ it('writes an importable slim profile and never changes the source', async () => {
+ const source = createSourceProfile()
+ const before = hashTree(source)
+ const target = path.join(mkdtempSync(path.join(tmpdir(), 'orca-seed-target-')), 'profile')
+ roots.push(path.dirname(target))
+
+ await seedDevProfile({ sourceDir: source, targetDir: target, report: () => {} })
+
+ expect(hashTree(source)).toBe(before)
+ const seeded = JSON.parse(
+ readFileSync(path.join(target, 'profiles', 'local-default', 'orca-data.json'), 'utf8')
+ )
+ expect(seeded).toEqual({
+ repos: [{ id: 'repo-1', path: '/src/app' }],
+ settings: { theme: 'dark' },
+ sshTargets: [{ id: 'ssh-1' }]
+ })
+ expect(
+ readFileSync(
+ path.join(target, 'profiles', 'local-default', 'account-session.json.enc'),
+ 'utf8'
+ )
+ ).toBe('{"ciphertext":"x"}')
+ expect(readdirSync(target).sort()).toEqual([
+ 'orca-dev-seed.json',
+ 'orca-profile-index.json',
+ 'profiles'
+ ])
+ })
+
+ it('refuses to overwrite a profile that already has state', async () => {
+ const source = createSourceProfile()
+ const target = mkdtempSync(path.join(tmpdir(), 'orca-seed-target-'))
+ roots.push(target)
+ writeFileSync(path.join(target, 'orca-profile-index.json'), '{"existing":true}')
+
+ await expect(
+ seedDevProfile({ sourceDir: source, targetDir: target, report: () => {} })
+ ).rejects.toThrow('already has state')
+ expect(readFileSync(path.join(target, 'orca-profile-index.json'), 'utf8')).toBe(
+ '{"existing":true}'
+ )
+ })
+})
diff --git a/config/scripts/orca-dev.mjs b/config/scripts/orca-dev.mjs
index bb4dca441fa..479b2489004 100755
--- a/config/scripts/orca-dev.mjs
+++ b/config/scripts/orca-dev.mjs
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process'
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'
import path from 'node:path'
import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs'
+import { resolveDevCliUserDataPath } from './dev-instance-isolation.mjs'
const scriptPath = realpathSync(import.meta.filename)
const scriptDir = path.dirname(scriptPath)
@@ -16,7 +17,8 @@ if (!existsSync(cliEntry)) {
process.exit(1)
}
-process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath()
+// Why: target the profile this checkout's `pnpm dev` uses (per-worktree in linked worktrees).
+process.env.ORCA_USER_DATA_PATH = resolveDevCliUserDataPath({ repoRoot })
// Why: custom dev profiles do not necessarily contain "orca-dev" in their path; carry explicit provenance into the CLI.
process.env.ORCA_DEV_CLI_INVOCATION = '1'
@@ -43,22 +45,6 @@ if (result.signal) {
}
process.exit(result.status ?? (result.error ? 1 : 0))
-function getDefaultDevUserDataPath() {
- if (process.platform === 'darwin') {
- return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
- }
- if (process.platform === 'win32') {
- return path.join(
- process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'),
- 'orca-dev'
- )
- }
- return path.join(
- process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'),
- 'orca-dev'
- )
-}
-
function getElectronExecutable() {
if (process.platform === 'win32') {
return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe')
diff --git a/config/scripts/reclaim-dev-worktree-profiles.mjs b/config/scripts/reclaim-dev-worktree-profiles.mjs
new file mode 100644
index 00000000000..52192ea7d51
--- /dev/null
+++ b/config/scripts/reclaim-dev-worktree-profiles.mjs
@@ -0,0 +1,133 @@
+#!/usr/bin/env node
+// Removes per-worktree `pnpm dev` profiles (see dev-instance-isolation.mjs) whose worktree is gone.
+import { lstat, readdir, readFile, readlink, realpath, rm } from 'node:fs/promises'
+import path from 'node:path'
+import {
+ getWorktreeDevProfilesRoot,
+ readWorktreeProfileMarker,
+ sameFilesystemPath,
+ WORKTREE_PROFILE_NAME_PATTERN
+} from './dev-instance-isolation.mjs'
+import { localRepositoryWorktrees } from './local-repository-worktrees.mjs'
+import { parseReclaimArguments } from './reclaim-worktree-command.mjs'
+import { isProcessAlive, readDaemonPids } from './remote-agent-session-process-cleanup.mjs'
+
+async function exists(filePath) {
+ try {
+ await lstat(filePath)
+ return true
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return false
+ }
+ throw error
+ }
+}
+
+async function canonical(filePath) {
+ try {
+ return await realpath(filePath)
+ } catch {
+ return path.resolve(filePath)
+ }
+}
+
+/** PIDs that may still hold the profile: the app (Chromium's lock), its runtime, and terminal daemons. */
+async function profileOwnerPids(userDataPath) {
+ const pids = [...readDaemonPids(userDataPath)]
+ try {
+ // POSIX Chromium writes SingletonLock as a symlink to `-`.
+ const match = /-(\d+)$/.exec(await readlink(path.join(userDataPath, 'SingletonLock')))
+ if (match) {
+ pids.push(Number(match[1]))
+ }
+ } catch {}
+ try {
+ const runtime = JSON.parse(await readFile(path.join(userDataPath, 'orca-runtime.json'), 'utf8'))
+ if (Number.isInteger(runtime?.pid)) {
+ pids.push(runtime.pid)
+ }
+ } catch {}
+ return pids
+}
+
+export async function reclaimDevWorktreeProfiles({
+ repoRoot = process.cwd(),
+ apply = false,
+ excludeWorktree,
+ profilesRoot = getWorktreeDevProfilesRoot(),
+ repository,
+ isAlive = isProcessAlive,
+ report = console.log
+} = {}) {
+ const { commonDirectory, worktrees } = repository ?? (await localRepositoryWorktrees(repoRoot))
+ const liveWorktrees = await Promise.all(worktrees.map(canonical))
+ const result = { entries: 0, protected: 0 }
+ let names
+ try {
+ names = await readdir(profilesRoot)
+ } catch (error) {
+ if (error.code === 'ENOENT') {
+ return result
+ }
+ throw error
+ }
+ for (const name of names) {
+ const profileDir = path.join(profilesRoot, name)
+ if (!WORKTREE_PROFILE_NAME_PATTERN.test(name) || !(await lstat(profileDir)).isDirectory()) {
+ continue
+ }
+ const marker = readWorktreeProfileMarker(profileDir)
+ // Profiles from other repositories belong to their own repository's cleanup.
+ if (
+ !marker?.gitCommonDir ||
+ !sameFilesystemPath(await canonical(marker.gitCommonDir), commonDirectory)
+ ) {
+ continue
+ }
+ if (
+ (excludeWorktree && sameFilesystemPath(marker.repoRoot, excludeWorktree)) ||
+ liveWorktrees.some((worktree) => sameFilesystemPath(worktree, marker.repoRoot)) ||
+ (await exists(marker.repoRoot))
+ ) {
+ continue
+ }
+ if ((await profileOwnerPids(profileDir)).some((pid) => isAlive(pid))) {
+ result.protected += 1
+ report(`retained dev profile ${JSON.stringify(profileDir)} (a process still uses it)`)
+ continue
+ }
+ if (apply) {
+ try {
+ await rm(profileDir, { recursive: true, force: true })
+ } catch (error) {
+ // Windows keeps a running profile's lock files open, so removal fails instead of racing.
+ result.protected += 1
+ report(`retained dev profile ${JSON.stringify(profileDir)} (${error.message})`)
+ continue
+ }
+ }
+ result.entries += 1
+ report(
+ `${apply ? 'removed' : 'would remove'} dev profile ${JSON.stringify(profileDir)} for removed worktree ${JSON.stringify(marker.repoRoot)}`
+ )
+ }
+ report(
+ `${apply ? 'Removed' : 'Would remove'} ${result.entries} dev profile(s) of removed worktrees; retained ${result.protected} in use.`
+ )
+ return result
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
+ const options = parseReclaimArguments(process.argv.slice(2))
+ if (options.help) {
+ console.log(
+ 'Usage: node config/scripts/reclaim-dev-worktree-profiles.mjs [--apply] [--repo DIRECTORY]'
+ )
+ } else {
+ reclaimDevWorktreeProfiles(options).catch((error) => {
+ console.error(error.message)
+ process.exitCode = 1
+ })
+ }
+}
diff --git a/config/scripts/reclaim-dev-worktree-profiles.test.mjs b/config/scripts/reclaim-dev-worktree-profiles.test.mjs
new file mode 100644
index 00000000000..07e1f1d4f1f
--- /dev/null
+++ b/config/scripts/reclaim-dev-worktree-profiles.test.mjs
@@ -0,0 +1,49 @@
+import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import { writeWorktreeProfileMarker } from './dev-instance-isolation.mjs'
+import { reclaimDevWorktreeProfiles } from './reclaim-dev-worktree-profiles.mjs'
+
+const roots = []
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ rmSync(root, { recursive: true, force: true })
+ }
+})
+
+it('removes only idle profiles whose worktree of this repository is gone', async () => {
+ const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'orca-dev-profiles-')))
+ roots.push(root)
+ const commonDirectory = path.join(root, 'repo', '.git')
+ const otherCommonDirectory = path.join(root, 'other', '.git')
+ const liveWorktree = path.join(root, 'live')
+ mkdirSync(commonDirectory, { recursive: true })
+ mkdirSync(otherCommonDirectory, { recursive: true })
+ mkdirSync(liveWorktree)
+ const profilesRoot = path.join(root, 'orca-dev-wt')
+ const profile = (name, repoRoot, gitCommonDir) => {
+ const dir = path.join(profilesRoot, name)
+ writeWorktreeProfileMarker(dir, { repoRoot, instanceKey: repoRoot, gitCommonDir })
+ return dir
+ }
+ const removed = profile('aaaaaaaaaa', path.join(root, 'removed'), commonDirectory)
+ const live = profile('bbbbbbbbbb', liveWorktree, commonDirectory)
+ const otherRepo = profile('cccccccccc', path.join(root, 'gone-other'), otherCommonDirectory)
+ const running = profile('dddddddddd', path.join(root, 'removed-running'), commonDirectory)
+ writeFileSync(path.join(running, 'orca-runtime.json'), JSON.stringify({ pid: 4242 }))
+
+ const result = await reclaimDevWorktreeProfiles({
+ apply: true,
+ profilesRoot,
+ repository: { commonDirectory, worktrees: [liveWorktree] },
+ isAlive: (pid) => pid === 4242,
+ report: () => {}
+ })
+
+ expect(result).toEqual({ entries: 1, protected: 1 })
+ expect(existsSync(removed)).toBe(false)
+ expect(existsSync(live)).toBe(true)
+ expect(existsSync(otherRepo)).toBe(true)
+ expect(existsSync(running)).toBe(true)
+})
diff --git a/config/scripts/reclaim-worktree-space.mjs b/config/scripts/reclaim-worktree-space.mjs
index 149dd5047a0..33d30ee92a9 100644
--- a/config/scripts/reclaim-worktree-space.mjs
+++ b/config/scripts/reclaim-worktree-space.mjs
@@ -51,7 +51,8 @@ export async function reclaimWorktreeSpace({
'reclaim-electron-dists.mjs',
'reclaim-dev-electron-bundles.mjs',
'reclaim-vite-caches.mjs',
- 'reclaim-worktree-generated-files.mjs'
+ 'reclaim-worktree-generated-files.mjs',
+ 'reclaim-dev-worktree-profiles.mjs'
]
if (!automatic && testsStopped) {
scripts.unshift('reclaim-release-test-caches.mjs')
diff --git a/config/scripts/reclaim-worktree-space.test.mjs b/config/scripts/reclaim-worktree-space.test.mjs
index c2d7f39724d..51e76af1d81 100644
--- a/config/scripts/reclaim-worktree-space.test.mjs
+++ b/config/scripts/reclaim-worktree-space.test.mjs
@@ -22,14 +22,14 @@ async function fixture() {
it('previews historical test caches, Electron cleanup, and Vite caches', async () => {
const options = { ...(await fixture()), testsStopped: true }
await reclaimWorktreeSpace(options)
- expect(options.execute).toHaveBeenCalledTimes(5)
+ expect(options.execute).toHaveBeenCalledTimes(6)
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args)).not.toContain('--apply')
})
it('forwards apply and repository selection to all cleanup operations', async () => {
const options = { ...(await fixture()), apply: true, testsStopped: true }
await reclaimWorktreeSpace(options)
- expect(options.execute).toHaveBeenCalledTimes(5)
+ expect(options.execute).toHaveBeenCalledTimes(6)
for (const [spec] of options.execute.mock.calls) {
expect(spec.args).toContain('--apply')
expect(spec.args).toContain(options.repoRoot)
@@ -46,7 +46,7 @@ it('forwards apply and repository selection to all cleanup operations', async ()
it('applies Electron, Vite and generated cleanup while retaining unacknowledged test caches', async () => {
const options = { ...(await fixture()), apply: true }
await reclaimWorktreeSpace(options)
- expect(options.execute).toHaveBeenCalledTimes(4)
+ expect(options.execute).toHaveBeenCalledTimes(5)
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain(
'reclaim-release-test-caches'
)
@@ -61,7 +61,7 @@ it('includes generated cleanup and leaves historical test caches out of automati
excludeWorktree: '/dev-worktree'
}
await reclaimWorktreeSpace(options)
- expect(options.execute).toHaveBeenCalledTimes(4)
+ expect(options.execute).toHaveBeenCalledTimes(5)
expect(options.execute.mock.calls.flatMap(([spec]) => spec.args).join(' ')).not.toContain(
'reclaim-release-test-caches'
)
@@ -191,7 +191,7 @@ it('rejects competing sweeps and releases the lock after a failed operation', as
await rejected
options.execute.mockResolvedValue({ code: 0, timedOut: false })
await reclaimWorktreeSpace(options)
- expect(options.execute).toHaveBeenCalledTimes(6)
+ expect(options.execute).toHaveBeenCalledTimes(7)
})
it('preserves the cleanup failure and reports a concurrent lock release failure separately', async () => {
@@ -218,5 +218,5 @@ it('fails if cleanup succeeds but the repository lock cannot be released', async
await expect(reclaimWorktreeSpace({ ...options, acquireLock: async () => release })).rejects.toBe(
failure
)
- expect(options.execute).toHaveBeenCalledTimes(5)
+ expect(options.execute).toHaveBeenCalledTimes(6)
})
diff --git a/config/scripts/remote-agent-session-process-cleanup.mjs b/config/scripts/remote-agent-session-process-cleanup.mjs
index a82173c0e94..a491f7484d7 100644
--- a/config/scripts/remote-agent-session-process-cleanup.mjs
+++ b/config/scripts/remote-agent-session-process-cleanup.mjs
@@ -11,7 +11,7 @@ export function isProcessAlive(pid) {
}
}
-function readDaemonPids(userDataPath) {
+export function readDaemonPids(userDataPath) {
const daemonDir = path.join(userDataPath, 'daemon')
if (!existsSync(daemonDir)) {
return []
diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs
index 35ce824317e..2e6e15e65e0 100644
--- a/config/scripts/run-electron-vite-dev.mjs
+++ b/config/scripts/run-electron-vite-dev.mjs
@@ -12,7 +12,6 @@ import {
symlinkSync,
writeFileSync
} from 'node:fs'
-import net from 'node:net'
import { createRequire } from 'node:module'
import path from 'node:path'
@@ -24,6 +23,14 @@ import {
selectStaleDevBundleDirs
} from './dev-electron-bundle-cache.mjs'
import { startDevStorageReclaim } from './dev-storage-reclaim.mjs'
+import {
+ announceDevInstance,
+ dropInheritedDevInstanceEnv,
+ resolveDevInstancePorts,
+ resolveDevProfile,
+ writeWorktreeProfileMarker
+} from './dev-instance-isolation.mjs'
+import { getDefaultSeedSource, profileHasState, seedDevProfile } from './dev-profile-seed.mjs'
import {
captureDevWebClientInputs,
isDevWebClientFresh,
@@ -50,10 +57,34 @@ const rawForwardedArgs = process.argv.slice(2)
// The flag is runner-only and must not leak into Chromium/electron-vite.
const useStableElectronName =
process.env.ORCA_DEV_STABLE_NAME === '1' || rawForwardedArgs.includes(STABLE_NAME_FLAG)
-const forwardedRaw = rawForwardedArgs.filter((arg) => arg !== STABLE_NAME_FLAG)
+const SHARED_PROFILE_FLAG = '--shared-profile'
+const WORKTREE_PROFILE_FLAG = '--worktree-profile'
+const SEED_PROFILE_FLAG = '--seed-profile'
+const isSeedProfileArg = (arg) =>
+ arg === SEED_PROFILE_FLAG || arg.startsWith(`${SEED_PROFILE_FLAG}=`)
+const seedProfileArg = rawForwardedArgs.findLast(isSeedProfileArg)
+// Why: profile flags are runner-only, like --stable-name.
+const forwardedRaw = rawForwardedArgs.filter(
+ (arg) =>
+ arg !== STABLE_NAME_FLAG &&
+ arg !== SHARED_PROFILE_FLAG &&
+ arg !== WORKTREE_PROFILE_FLAG &&
+ !isSeedProfileArg(arg)
+)
+const requestedProfileMode = rawForwardedArgs.includes(SHARED_PROFILE_FLAG)
+ ? 'shared'
+ : rawForwardedArgs.includes(WORKTREE_PROFILE_FLAG)
+ ? 'worktree'
+ : undefined
+// `--seed-profile` uses the default source; `--seed-profile=` or ORCA_DEV_SEED_PROFILE= pick one.
+const seedProfileRequest =
+ seedProfileArg?.slice(SEED_PROFILE_FLAG.length + 1) ?? process.env.ORCA_DEV_SEED_PROFILE ?? null
if (useStableElectronName) {
process.env.ORCA_DEV_STABLE_NAME = '1'
}
+// Why: --help/--version exit immediately; binding probe sockets or printing instance lines is noise.
+const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
+dropInheritedDevInstanceEnv(process.env, repoRoot)
function readGitValue(args) {
try {
@@ -431,27 +462,35 @@ function restoreElectronFrameworkSymlinks(appPath) {
}
}
-function getDevUserDataPath() {
- if (process.env.ORCA_DEV_USER_DATA_PATH) {
- return process.env.ORCA_DEV_USER_DATA_PATH
- }
- if (process.platform === 'darwin') {
- return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
- }
- if (process.platform === 'win32') {
- return path.join(
- process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'),
- 'orca-dev'
- )
- }
- return path.join(
- process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'),
- 'orca-dev'
- )
+function readGitCommonDir() {
+ const value = readGitValue(['rev-parse', '--git-common-dir'])
+ return value ? path.resolve(repoRoot, value) : null
}
-function prepareDevCliWrapper() {
- const userDataPath = getDevUserDataPath()
+async function prepareDevProfile({ kind, userDataPath }) {
+ if (kind === 'worktree') {
+ // Why: storage reclaim uses this marker to delete the profile once its worktree is removed.
+ writeWorktreeProfileMarker(userDataPath, {
+ repoRoot,
+ instanceKey: process.env.ORCA_DEV_INSTANCE_KEY,
+ gitCommonDir: readGitCommonDir()
+ })
+ }
+ if (seedProfileRequest === null || profileHasState(userDataPath)) {
+ return
+ }
+ const sourceDir =
+ seedProfileRequest === '' || seedProfileRequest === '1'
+ ? getDefaultSeedSource()
+ : path.resolve(seedProfileRequest)
+ try {
+ await seedDevProfile({ sourceDir, targetDir: userDataPath })
+ } catch (error) {
+ console.error(`[orca-dev] Profile seed skipped: ${error.message}`)
+ }
+}
+
+function prepareDevCliWrapper(userDataPath) {
const { binDir } = prepareDevCliTerminalWrappers({
repoRoot,
userDataPath,
@@ -469,11 +508,22 @@ function getElectronExecutable() {
return path.join(repoRoot, 'node_modules', '.bin', 'electron')
}
+seedDevInstanceIdentityEnv()
+const devProfile = resolveDevProfile({
+ repoRoot,
+ instanceKey: process.env.ORCA_DEV_INSTANCE_KEY,
+ mode: requestedProfileMode
+})
+// Why: Electron main reads this; without it main falls back to the shared orca-dev profile.
+process.env.ORCA_DEV_USER_DATA_PATH = devProfile.userDataPath
+if (!isHelpOrVersion) {
+ await prepareDevProfile(devProfile)
+}
+
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
- prepareDevCliWrapper()
+ prepareDevCliWrapper(devProfile.userDataPath)
}
-seedDevInstanceIdentityEnv()
if (!useStableElectronName && process.env.ORCA_SKIP_DEV_ELECTRON_APP_PREPARE !== '1') {
prepareMacDevElectronApp()
}
@@ -528,58 +578,6 @@ function prepareDevWebClient() {
recordDevWebClientInputs(repoRoot, inputs)
}
-// Why: every `pn dev` should be attachable from agent-browser/playwright-cli
-// without manual port juggling. Pick a best-effort deterministic port per
-// worktree; falls back to a probe sweep if the deterministic pick or its
-// neighbors are busy (multiple worktrees may share a machine).
-function isPortFree(port) {
- return new Promise((resolve) => {
- const srv = net.createServer()
- srv.once('error', () => {
- // Why: error fires before listen binds; close() may throw — swallow it
- // so the handle is released without leaking listeners across 64 probes.
- try {
- srv.close()
- } catch {}
- resolve(false)
- })
- srv.once('listening', () => srv.close(() => resolve(true)))
- srv.listen(port, '127.0.0.1')
- })
-}
-async function pickDebugPort() {
- // Why: 32 bits of SHA1 (vs 16) reduces truncation bias; modulo 200 still
- // collides routinely across many worktrees, hence the probe sweep below.
- const seed = Number.parseInt(createHash('sha1').update(repoRoot).digest('hex').slice(0, 8), 16)
- const base = 9333 + (seed % 200) // deterministic base in 9333..9532; probe sweeps up to base+63
- for (let i = 0; i < 64; i++) {
- const p = base + i
- if (await isPortFree(p)) {
- return p
- }
- }
- return null
-}
-function parseDebugPortEnv(raw) {
- const n = Number.parseInt(raw, 10)
- if (!Number.isInteger(n) || n < 1 || n > 65535 || String(n) !== raw.trim()) {
- return null
- }
- return n
-}
-// Why: exact match (or `=` form) avoids false positives on hypothetical
-// `--remote-debugging-port-*` flags; the bare flag also covers the
-// space-separated form. `--remote-debugging-pipe` opts into pipe-based
-// debugging — don't fight the user's choice by injecting a port.
-const userPassedPort = forwardedRaw.some(
- (a) =>
- a === '--remote-debugging-port' ||
- a.startsWith('--remote-debugging-port=') ||
- a === '--remote-debugging-pipe'
-)
-// Why: --help/--version exit immediately; binding a probe socket and printing
-// a debug-port line would be noise.
-const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) {
console.error(`[orca-dev] Instance: ${process.env.ORCA_DEV_INSTANCE_LABEL}`)
}
@@ -589,31 +587,13 @@ if (!isHelpOrVersion && process.env.ORCA_BACKGROUND_LAUNCH === '1') {
console.error('[orca-dev] Background launch: window stays off screen; automate through CDP')
}
let forwardedExtras = []
-if (!userPassedPort && !isHelpOrVersion) {
- const envPortRaw = process.env.REMOTE_DEBUGGING_PORT
- let port = null
- if (envPortRaw) {
- port = parseDebugPortEnv(envPortRaw)
- if (port === null) {
- console.error(
- `[orca-dev] Ignoring invalid REMOTE_DEBUGGING_PORT=${JSON.stringify(envPortRaw)}; falling back to probe.`
- )
- }
- }
- if (port === null) {
- port = await pickDebugPort()
- }
- if (port !== null) {
- forwardedExtras = [`--remote-debugging-port=${port}`]
- // Why: stderr keeps stdout clean for downstream parsing; log uses
- // 127.0.0.1 to match the interface we actually probed (localhost may
- // resolve to ::1 on IPv6-first hosts).
- console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`)
- } else {
- console.error(
- '[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.'
- )
- }
+if (!isHelpOrVersion) {
+ const ports = await resolveDevInstancePorts({
+ forwardedArgs: forwardedRaw,
+ instanceKey: process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
+ })
+ forwardedExtras = ports.forwardedExtras
+ announceDevInstance({ repoRoot, profile: devProfile, ...ports })
}
prepareDevWebClient()
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
diff --git a/docs/readme/worktree-storage-maintenance.md b/docs/readme/worktree-storage-maintenance.md
index c5248aadb51..e9383db646b 100644
--- a/docs/readme/worktree-storage-maintenance.md
+++ b/docs/readme/worktree-storage-maintenance.md
@@ -83,7 +83,9 @@ Read-only Electron package directories are retained and reported as skipped. Cac
or copying failures are also reported as skipped rather than silently omitted.
`pnpm dev` and `pnpm dev-stable-name` start an automatic repository-wide cleanup worker for
-Electron, Vite caches, and old dependency installs and generated output
+Electron, Vite caches, old dependency installs and generated output, and per-worktree dev
+profiles whose worktree was removed (a profile is kept while its app, runtime, or terminal daemon
+is still running),
in the background after the dev process starts. It never prompts or waits before launching the app.
The worktree being launched is excluded from every cleanup pass. Existing bundle checks protect
active sibling Electron bundles and recent unfinished bundles from the bundle-specific pass;
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 708b8031358..ca448cb89b1 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -11,6 +11,7 @@ import { createProcessHostDevRebuildPlugin } from './config/build-plugins/proces
import { createViteCacheRetentionPlugin } from './config/build-plugins/vite-cache-retention'
import { MAIN_BUILD_ENTRY_POINTS } from './config/build-plugins/main-build-entry-points'
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
+import { createDevServeConfig } from './config/build-plugins/dev-serve-config'
import { resolveBuildSourceCommit } from './config/scripts/build-source-commit.mjs'
const BUNDLED_MAIN_DEPENDENCIES = new Set([
@@ -342,23 +343,6 @@ export const electronViteConfig: UserConfig = {
}
}
-export default defineConfig(({ command }) => {
- if (command !== 'serve') {
- return electronViteConfig
- }
- // Electron Vite also uses Vite's build command for the development main process.
- return {
- ...electronViteConfig,
- main: {
- ...electronViteConfig.main,
- build: {
- ...electronViteConfig.main?.build,
- minify: false,
- sourcemap:
- process.env.ORCA_DEV_MAIN_SOURCEMAPS === '0'
- ? false
- : electronViteConfig.main?.build?.sourcemap
- }
- }
- }
-})
+export default defineConfig(({ command }) =>
+ command === 'serve' ? createDevServeConfig(electronViteConfig) : electronViteConfig
+)
diff --git a/package.json b/package.json
index d58b95a02cc..28d90cc587c 100644
--- a/package.json
+++ b/package.json
@@ -95,6 +95,8 @@
"start": "pnpm run build:packages && pnpm run ensure:electron-runtime && electron-vite preview",
"dev": "pnpm run build:packages && pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs",
"dev-stable-name": "pnpm run build:packages && pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name",
+ "dev:fresh": "pnpm run build:packages && node config/scripts/dev-fresh-profile.mjs",
+ "dev:seed-profile": "node config/scripts/dev-profile-seed.mjs",
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
"build:wsl-guest": "pnpm run build:packages && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-wsl-guest.mjs",
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
diff --git a/src/main/command-code/command-code-managed-script.ts b/src/main/command-code/command-code-managed-script.ts
index a370482ad1b..c8ddf00c420 100644
--- a/src/main/command-code/command-code-managed-script.ts
+++ b/src/main/command-code/command-code-managed-script.ts
@@ -24,8 +24,14 @@ export function buildCommandCodeManagedScript(
':sourceEndpointByPort',
'if not defined APPDATA exit /b 0',
'if exist "%APPDATA%\\orca-dev\\agent-hooks" for /r "%APPDATA%\\orca-dev\\agent-hooks" %%F in (endpoint.cmd) do call :maybeSourceEndpoint "%%~fF"',
+ // Per-worktree `pnpm dev` profiles live one level down; FOR /R cannot take a FOR variable as its root.
+ 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if exist "%APPDATA%\\orca-dev-wt" for /d %%P in ("%APPDATA%\\orca-dev-wt\\*") do call :scanEndpointDir "%%~fP\\agent-hooks"',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" if exist "%APPDATA%\\orca\\agent-hooks" for /r "%APPDATA%\\orca\\agent-hooks" %%F in (endpoint.cmd) do call :maybeSourceEndpoint "%%~fF"',
'exit /b 0',
+ ':scanEndpointDir',
+ 'if not "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
+ 'if exist "%~1" for /r "%~1" %%F in (endpoint.cmd) do call :maybeSourceEndpoint "%%~fF"',
+ 'exit /b 0',
':maybeSourceEndpoint',
'if not "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'for /f "tokens=2 delims==" %%P in (\'findstr /b /c:"set ORCA_AGENT_HOOK_PORT=" "%~1" 2^>nul\') do if "%%P"=="%ORCA_AGENT_HOOK_PORT%" call "%~1" 2>nul',
@@ -108,6 +114,8 @@ export function buildCommandCodeManagedScript(
' "$HOME/Library/Application Support/orca-dev/agent-hooks/endpoint.env" \\',
' "${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev/agent-hooks"/*/endpoint.env \\',
' "${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev/agent-hooks/endpoint.env" \\',
+ ' "$HOME/Library/Application Support/orca-dev-wt"/*/agent-hooks/*/endpoint.env \\',
+ ' "${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev-wt"/*/agent-hooks/*/endpoint.env \\',
' "$HOME/Library/Application Support/orca/agent-hooks/endpoint.env" \\',
' "${XDG_CONFIG_HOME:-$HOME/.config}/orca/agent-hooks/endpoint.env"; do',
' [ -r "$endpoint" ] || continue',
diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts
index f7e9125ae5a..e0991aaff5b 100644
--- a/src/main/startup/configure-process.ts
+++ b/src/main/startup/configure-process.ts
@@ -217,11 +217,11 @@ export function configureDevUserDataPath(isDev: boolean): void {
}
const overrideUserDataPath = process.env.ORCA_DEV_USER_DATA_PATH
if (overrideUserDataPath) {
- // Why: automated repros need an isolated profile so the dev's persisted tabs/worktrees don't skew startup and hide window bugs.
+ // Why: run-electron-vite-dev.mjs resolves a per-worktree (or shared orca-dev) profile here; repros also pass isolated ones.
app.setPath('userData', overrideUserDataPath)
return
}
- // Why: without a dev-only path, pnpm dev overwrites the packaged app's runtime pointer under userData and breaks the orca CLI.
+ // Why: launches that bypass the dev runner still need a dev-only path, or they overwrite the packaged app's runtime pointer under userData and break the orca CLI.
app.setPath('userData', join(app.getPath('appData'), 'orca-dev'))
}
diff --git a/src/main/startup/run-electron-vite-dev-web.test.ts b/src/main/startup/run-electron-vite-dev-web.test.ts
index 9b94938bcc9..f36502b3f09 100644
--- a/src/main/startup/run-electron-vite-dev-web.test.ts
+++ b/src/main/startup/run-electron-vite-dev-web.test.ts
@@ -156,6 +156,7 @@ describe('run-electron-vite-dev web client prepare', () => {
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_VITE_CLI: fakeVitePath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
+ ORCA_DEV_USER_DATA_PATH: join(tempDir, 'user-data'),
ORCA_SKIP_DEV_STORAGE_RECLAIM: '1',
ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1',
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile,
@@ -208,6 +209,7 @@ describe('run-electron-vite-dev web client prepare', () => {
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_VITE_CLI: fakeVitePath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
+ ORCA_DEV_USER_DATA_PATH: join(tempDir, 'user-data'),
ORCA_SKIP_DEV_STORAGE_RECLAIM: '1',
ORCA_SKIP_DEV_ELECTRON_APP_PREPARE: '1',
ORCA_DEV_WEB_PREPARE: '1',
diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts
index 1c0469f7664..961c6d7eaf7 100644
--- a/src/main/startup/run-electron-vite-dev.test.ts
+++ b/src/main/startup/run-electron-vite-dev.test.ts
@@ -102,7 +102,13 @@ function devWrapperTestEnv(extra: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
}
}
delete env.ELECTRON_EXEC_PATH
- return { ...env, ORCA_SKIP_DEV_STORAGE_RECLAIM: '1', ...extra }
+ return {
+ ...env,
+ ORCA_SKIP_DEV_STORAGE_RECLAIM: '1',
+ // Why: the runner otherwise creates this worktree's real per-worktree profile under appData.
+ ORCA_DEV_USER_DATA_PATH: join(tmpdir(), 'orca-dev-wrapper-test-profile'),
+ ...extra
+ }
}
/**
diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts
index 3bb4221fcb8..eb910066138 100644
--- a/src/main/startup/single-instance-lock.ts
+++ b/src/main/startup/single-instance-lock.ts
@@ -86,7 +86,7 @@ export function singleInstanceLockFailureMessage(options: {
if (!options.isDevDesktop) {
return SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE
}
- return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after passing it this launch's request. To run another dev copy at the same time, give it its own profile: ORCA_DEV_USER_DATA_PATH= pnpm dev`
+ return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after passing it this launch's request. To run another dev copy at the same time, give it its own profile: \`pnpm dev --worktree-profile\` (the default in linked worktrees) or ORCA_DEV_USER_DATA_PATH= pnpm dev`
}
export function logSingleInstanceLockFailure(
diff --git a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts
index d7128d1d271..8c7e6ebcea7 100644
--- a/tests/e2e/helpers/daemon-generation-runtime-fixture.ts
+++ b/tests/e2e/helpers/daemon-generation-runtime-fixture.ts
@@ -53,14 +53,26 @@ function isEqualToOrInside(candidate: string, parent: string): boolean {
function knownOrcaUserDataDirs(): string[] {
if (process.platform === 'darwin') {
const appSupport = path.join(homedir(), 'Library', 'Application Support')
- return [path.join(appSupport, 'orca'), path.join(appSupport, 'orca-dev')]
+ return [
+ path.join(appSupport, 'orca'),
+ path.join(appSupport, 'orca-dev'),
+ path.join(appSupport, 'orca-dev-wt')
+ ]
}
if (process.platform === 'win32') {
const roaming = process.env.APPDATA ?? path.join(homedir(), 'AppData', 'Roaming')
- return [path.join(roaming, 'orca'), path.join(roaming, 'orca-dev')]
+ return [
+ path.join(roaming, 'orca'),
+ path.join(roaming, 'orca-dev'),
+ path.join(roaming, 'orca-dev-wt')
+ ]
}
const config = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config')
- return [path.join(config, 'orca'), path.join(config, 'orca-dev')]
+ return [
+ path.join(config, 'orca'),
+ path.join(config, 'orca-dev'),
+ path.join(config, 'orca-dev-wt')
+ ]
}
function assertDisposableRoot(rootDir: string): void {
diff --git a/tests/tools/relay-bench/cdp-eval.mjs b/tests/tools/relay-bench/cdp-eval.mjs
index 23c96ab5a2c..1e82df8560c 100644
--- a/tests/tools/relay-bench/cdp-eval.mjs
+++ b/tests/tools/relay-bench/cdp-eval.mjs
@@ -3,11 +3,12 @@ import WebSocket from 'ws'
import { requirePort } from './relay-bench-invocation.mjs'
const USAGE = 'node cdp-eval.mjs '
-const RENDERER_ORIGIN = 'http://localhost:5173'
+// Why any port: `pnpm dev` picks a per-worktree renderer port (see the ORCA_DEV_INSTANCE line).
+const RENDERER_ORIGIN = /^http:\/\/localhost:\d+\//
const OPEN_TIMEOUT_MS = 5_000
function findRendererPage(list) {
- return list.find((p) => p.type === 'page' && p.url.startsWith(RENDERER_ORIGIN))
+ return list.find((p) => p.type === 'page' && RENDERER_ORIGIN.test(p.url))
}
function describePages(list) {
@@ -21,7 +22,7 @@ const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = findRendererPage(list)
if (!page) {
console.error(
- `no renderer page at ${RENDERER_ORIGIN} on devtools port ${port}; pages: ${describePages(list)}`
+ `no localhost renderer page on devtools port ${port}; pages: ${describePages(list)}`
)
process.exit(1)
}