diff --git a/scripts/dev-slot.mjs b/scripts/dev-slot.mjs
index 6c192241e..d608b370e 100755
--- a/scripts/dev-slot.mjs
+++ b/scripts/dev-slot.mjs
@@ -19,7 +19,7 @@ const SCRIPT_PATH = fileURLToPath(import.meta.url)
function usage() {
return `Usage:
- scripts/dev-slot.mjs start --db empty|copy [--worktree PATH] [--open]
+ scripts/dev-slot.mjs start --db empty|copy [--worktree PATH] [--open|--desktop]
scripts/dev-slot.mjs status [--worktree PATH]
scripts/dev-slot.mjs stop [--worktree PATH]
scripts/dev-slot.mjs cleanup [--worktree PATH]
@@ -35,7 +35,7 @@ export function parseArgs(argv) {
throw new Error(usage())
}
- const options = { command, worktree: process.cwd(), db: null, open: false }
+ const options = { command, worktree: process.cwd(), db: null, open: false, desktop: false }
for (let index = 0; index < rest.length; index += 1) {
const arg = rest[index]
if (arg === '--worktree') {
@@ -50,6 +50,8 @@ export function parseArgs(argv) {
index += 1
} else if (arg === '--open') {
options.open = true
+ } else if (arg === '--desktop') {
+ options.desktop = true
} else if (arg === '--help' || arg === '-h') {
throw new Error(usage())
} else {
@@ -60,9 +62,11 @@ export function parseArgs(argv) {
if (command === 'start' && options.db === null) {
throw new Error('start requires --db empty or --db copy')
}
- if (command !== 'start' && (options.db !== null || options.open)) {
- throw new Error(`--db and --open are only valid with start`)
+ if (command !== 'start' && (options.db !== null || options.open || options.desktop)) {
+ throw new Error(`--db, --open and --desktop are only valid with start`)
}
+ if (options.open && options.desktop) throw new Error('--open and --desktop cannot be combined')
+ if (options.desktop && process.platform !== 'darwin') throw new Error('--desktop requires macOS')
return options
}
@@ -75,6 +79,17 @@ function worktreeInfo(worktreeInput) {
const manifestPath = path.join(worktreePath, 'Cargo.toml')
if (!existsSync(manifestPath)) throw new Error(`Not an openresearch-cli worktree: ${worktreePath}`)
+ if (existsSync(path.join(worktreePath, '.jj'))) {
+ const root = realpathSync(run('jj', ['--ignore-working-copy', '--repository', worktreePath, 'root']))
+ return {
+ worktreePath: root, manifestPath: path.join(root, 'Cargo.toml'),
+ worktreeKey: createHash('sha256').update(root).digest('hex').slice(0, 16),
+ label: path.basename(root).replaceAll(/[^a-zA-Z0-9._-]/g, '-'),
+ mainCheckout: root,
+ jj: true,
+ }
+ }
+
const records = run('git', ['-C', worktreePath, 'worktree', 'list', '--porcelain'])
.split(/\n\n+/)
.map((block) => Object.fromEntries(block.split('\n').map((line) => {
@@ -98,7 +113,7 @@ function pathsFor(info, slot = null) {
const base = {
allocatorRoot,
cargoTargetDir: path.join(allocatorRoot, 'cargo-target'),
- launchRegistry: path.join(info.mainCheckout, '.claude', 'launch.json'),
+ launchRegistry: info.jj ? path.join(allocatorRoot, 'jj-launch.json') : path.join(info.mainCheckout, '.claude', 'launch.json'),
lockDir: path.join(allocatorRoot, 'slot-allocator.lock'),
lifecycleLockDir: path.join(allocatorRoot, 'lifecycle-locks', info.worktreeKey),
}
@@ -586,14 +601,14 @@ async function waitForUi(state) {
function ensureUiDependencies(info) {
if (existsSync(path.join(info.worktreePath, 'ui', 'node_modules'))) return
- execFileSync('pnpm', ['-C', path.join(info.worktreePath, 'ui'), 'install', '--frozen-lockfile'], { stdio: 'inherit' })
+ execFileSync('pnpm', ['-C', path.join(info.worktreePath, 'ui'), 'install', '--frozen-lockfile', '--config.strictDepBuilds=false'], { stdio: 'inherit' })
}
function saveState(slotPaths, state) {
atomicWriteJson(slotPaths.statePath, state)
}
-async function startUnlocked(info, dbMode, openBrowser) {
+async function startUnlocked(info, dbMode, openBrowser, desktop) {
const reservation = await reserveSlot(info, dbMode)
const { slotPaths } = reservation
const state = reservation.state
@@ -642,7 +657,7 @@ async function startUnlocked(info, dbMode, openBrowser) {
'dev', '--port', String(slotPaths.uiPort), '--strictPort',
], {
cwd: info.worktreePath,
- env: { ...process.env, ORX_BACKEND: `http://127.0.0.1:${slotPaths.backendPort}` },
+ env: { ...process.env, npm_config_strict_dep_builds: 'false', ORX_BACKEND: `http://127.0.0.1:${slotPaths.backendPort}` },
}, state.uiLog)
saveState(slotPaths, state)
}
@@ -653,6 +668,22 @@ async function startUnlocked(info, dbMode, openBrowser) {
state.phase = 'running'
saveState(slotPaths, state)
+ if (desktop) {
+ execFileSync('cargo', ['build', '--manifest-path', info.manifestPath], {
+ cwd: info.worktreePath, env: { ...process.env, ...slotEnvironment(slotPaths) }, stdio: 'inherit',
+ })
+ const contents = path.join(slotPaths.cacheDir, 'OpenResearch Dev.app', 'Contents')
+ mkdirSync(path.join(contents, 'MacOS'), { recursive: true })
+ const executable = path.join(contents, 'MacOS', 'OpenResearch Dev')
+ cpSync(path.join(slotPaths.cargoTargetDir, 'debug', 'orx'), executable)
+ writeFileSync(path.join(contents, 'Info.plist'), `\n\nCFBundleExecutableOpenResearch DevCFBundleIdentifierorg.alphaxiv.openresearch.dev.slot${slotPaths.slot}CFBundleNameOpenResearch DevCFBundlePackageTypeAPPL\n`)
+ const child = spawn(executable, ['--dev-desktop', String(slotPaths.uiPort)], {
+ detached: true, stdio: 'ignore', env: { ...process.env, ...slotEnvironment(slotPaths) },
+ })
+ child.on('error', (error) => console.error(`Could not open the desktop app: ${error.message}`))
+ child.unref()
+ }
+
if (openBrowser) {
const url = `http://localhost:${slotPaths.uiPort}/`
const opener = process.platform === 'darwin'
@@ -673,10 +704,10 @@ async function startUnlocked(info, dbMode, openBrowser) {
console.log(` State: ${slotPaths.statePath}`)
}
-async function start(info, dbMode, openBrowser) {
+async function start(info, dbMode, openBrowser, desktop) {
const release = await acquireLifecycleLock(info)
try {
- await startUnlocked(info, dbMode, openBrowser)
+ await startUnlocked(info, dbMode, openBrowser, desktop)
} finally {
await release()
}
@@ -850,7 +881,7 @@ async function main() {
}
const options = parseArgs(process.argv.slice(2))
const info = worktreeInfo(options.worktree)
- if (options.command === 'start') await start(info, options.db, options.open)
+ if (options.command === 'start') await start(info, options.db, options.open, options.desktop)
if (options.command === 'status') printStatus(info)
if (options.command === 'stop') await stopOwnedProcesses(info)
if (options.command === 'cleanup') await cleanup(info)
diff --git a/scripts/dev-slot.test.mjs b/scripts/dev-slot.test.mjs
index 49881a53d..4e5b0d155 100644
--- a/scripts/dev-slot.test.mjs
+++ b/scripts/dev-slot.test.mjs
@@ -24,6 +24,17 @@ test('requires an explicit database mode when starting', () => {
assert.equal(parseArgs(['start', '--db', 'copy', '--open']).open, true)
})
+test('desktop launch is optional and exclusive to start', () => {
+ assert.equal(parseArgs(['start', '--db', 'empty']).desktop, false)
+ assert.throws(() => parseArgs(['status', '--desktop']), /only valid with start/)
+ assert.throws(() => parseArgs(['start', '--db', 'empty', '--open', '--desktop']), /cannot be combined/)
+ if (process.platform === 'darwin') {
+ assert.equal(parseArgs(['start', '--db', 'empty', '--desktop']).desktop, true)
+ } else {
+ assert.throws(() => parseArgs(['start', '--db', 'empty', '--desktop']), /requires macOS/)
+ }
+})
+
test('empty mode creates no database', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'orx-dev-slot-empty-'))
try {
diff --git a/src/commands/app.rs b/src/commands/app.rs
index 7cf1d94f2..65a7014a8 100644
--- a/src/commands/app.rs
+++ b/src/commands/app.rs
@@ -186,9 +186,14 @@ pub async fn run() {
.unwrap_or(4791)
});
#[cfg(target_os = "macos")]
- imp::run_event_loop(port);
+ imp::run_event_loop(port, false);
#[cfg(not(target_os = "macos"))]
- imp::run_event_loop(port, focus_requests);
+ imp::run_event_loop(port, false, focus_requests);
+}
+
+#[cfg(all(target_os = "macos", debug_assertions))]
+pub fn run_dev(port: u16) {
+ imp::run_event_loop(port, true);
}
/// Adopt the user's shell environment in place of the one launchd or the desktop
@@ -661,9 +666,17 @@ mod imp {
pub(super) fn run_event_loop(
port: u16,
+ external_server: bool,
#[cfg(not(target_os = "macos"))] focus_requests: super::instance::FocusRequests,
) {
- let origin = format!("http://127.0.0.1:{port}");
+ let origin = format!(
+ "http://{}:{port}",
+ if external_server {
+ "localhost"
+ } else {
+ "127.0.0.1"
+ }
+ );
// Before GTK starts: the window class the dock matches to the desktop
// entry's StartupWMClass.
#[cfg(target_os = "linux")]
@@ -671,32 +684,41 @@ mod imp {
let event_loop = EventLoopBuilder::::with_user_event().build();
// Dashboard server on background workers (we're inside main's runtime).
- tokio::spawn(async move {
- let args = crate::UpArgs {
- port,
- remote: None,
- no_browser: true,
- no_agent: false,
- model: None,
- remote_host: false,
- desktop_app: true,
- };
- // The window is useless without its server, so the app goes with it.
- match crate::commands::up::run(args).await {
- Ok(()) => std::process::exit(0),
- Err(err) => {
- eprintln!("openresearch app: dashboard server exited: {err}");
- crate::show_error_dialog(&format!("The dashboard server stopped: {err}"));
- std::process::exit(1);
+ if !external_server {
+ tokio::spawn(async move {
+ let args = crate::UpArgs {
+ port,
+ remote: None,
+ no_browser: true,
+ no_agent: false,
+ model: None,
+ remote_host: false,
+ desktop_app: true,
+ };
+ // The window is useless without its server, so the app goes with it.
+ match crate::commands::up::run(args).await {
+ Ok(()) => std::process::exit(0),
+ Err(err) => {
+ eprintln!("openresearch app: dashboard server exited: {err}");
+ crate::show_error_dialog(&format!("The dashboard server stopped: {err}"));
+ std::process::exit(1);
+ }
}
- }
- });
+ });
+ }
let ready = event_loop.create_proxy();
tokio::spawn(async move {
- while tokio::net::TcpStream::connect(("127.0.0.1", port))
- .await
- .is_err()
+ while tokio::net::TcpStream::connect((
+ if external_server {
+ "localhost"
+ } else {
+ "127.0.0.1"
+ },
+ port,
+ ))
+ .await
+ .is_err()
{
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -960,6 +982,10 @@ mod imp {
// The server's own shutdown path stops the agents and then exits
// the process.
Quit::Flushing => {
+ if external_server {
+ *control_flow = ControlFlow::Exit;
+ return;
+ }
crate::commands::up::request_shutdown();
quit = Quit::ShuttingDown;
*control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_secs(5));
diff --git a/src/main.rs b/src/main.rs
index fe4a5d421..299abe474 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1005,6 +1005,19 @@ fn explain_removed_spawn_flags(mut error: clap::Error) -> clap::Error {
// on worker threads. A `current_thread` flavor would deadlock. See commands::app.
#[tokio::main]
async fn main() {
+ #[cfg(all(target_os = "macos", debug_assertions))]
+ if std::env::args().nth(1).as_deref() == Some("--dev-desktop") {
+ let port = std::env::args()
+ .nth(2)
+ .and_then(|arg| arg.parse::().ok());
+ if let Some(port) = port.filter(|port| (5201..=5209).contains(port)) {
+ commands::app::run_dev(port);
+ } else {
+ eprintln!("--dev-desktop requires a dev slot UI port (5201–5209)");
+ std::process::exit(1);
+ }
+ return;
+ }
if std::env::var_os("ORX_SSH_ASKPASS_ENDPOINT").is_some() {
if jobs::ssh::service::askpass().await.is_err() {
std::process::exit(1);