diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs new file mode 100755 index 000000000..6d4f7ac86 --- /dev/null +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -0,0 +1,326 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { hostname, tmpdir } from 'node:os' +import path from 'node:path' +import { createInterface } from 'node:readline' + +const scriptDir = import.meta.dirname +const repoRoot = path.resolve(scriptDir, '..', '..') +const orcaDevScript = path.join(scriptDir, 'orca-dev.mjs') +const ensureNativeRuntimeScript = path.join(scriptDir, 'ensure-native-runtime.mjs') +const fixedProfileDir = process.env.ORCA_HEADLESS_PAIRING_PROFILE_DIR +const parsed = parseArgs(process.argv.slice(2)) + +if (parsed.help) { + printHelp() + process.exit(0) +} + +if (hasForwardedServeFlag(parsed.serveArgs, 'no-pairing')) { + console.error('serve-headless-fresh-profile-pairing: --no-pairing cannot print a pairing code.') + process.exit(2) +} +if (hasForwardedServeFlag(parsed.serveArgs, 'recipe-json')) { + console.error( + 'serve-headless-fresh-profile-pairing: --recipe-json detaches the server and cannot use an ephemeral profile safely.' + ) + process.exit(2) +} + +const serveArgs = withDefaultPairingAddress(parsed.serveArgs) + +ensureElectronRuntime() + +const profileDir = + fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-')) +const ownsProfileDir = !fixedProfileDir +mkdirSync(profileDir, { recursive: true }) + +let cleanedUp = false +let child = null +let sawPairingUrl = false +let stopAttempts = 0 +// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script +// is only for local headless testing, not packaged production. +const childEnv = { + ...process.env, + ORCA_DEV_USER_DATA_PATH: profileDir, + ...(process.platform === 'linux' + ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } + : {}) +} + +console.error(`[headless-pairing] userData=${profileDir}`) +console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`) + +child = spawn(process.execPath, [orcaDevScript, 'serve', '--json', ...serveArgs], { + cwd: repoRoot, + detached: process.platform !== 'win32', + env: childEnv, + stdio: ['inherit', 'pipe', 'inherit'] +}) + +const stdoutLines = createInterface({ input: child.stdout }) +stdoutLines.on('line', (line) => { + if (!printReadyLine(line)) { + console.log(line) + } +}) + +child.once('error', (error) => { + console.error(`[headless-pairing] failed to start server: ${error.message}`) + cleanupProfile() + process.exitCode = 1 +}) + +child.once('exit', (code, signal) => { + stdoutLines.close() + if (!sawPairingUrl && code !== 0) { + console.error('[headless-pairing] server exited before printing a pairing URL.') + } + cleanupProfile() + if (typeof code === 'number') { + process.exitCode = code + return + } + process.exitCode = signal ? 1 : 0 +}) + +process.on('SIGINT', () => stopChild('SIGINT')) +process.on('SIGTERM', () => stopChild('SIGTERM')) + +/** + * Parses wrapper flags and forwards everything else to `orca serve`. + */ +function parseArgs(args) { + const serveArgs = [] + let keepProfile = false + let help = false + for (const arg of args) { + if (arg === '--keep') { + keepProfile = true + continue + } + if (arg === '-h' || arg === '--help') { + help = true + continue + } + serveArgs.push(arg) + } + return { help, keepProfile, serveArgs } +} + +/** + * Prints script usage without touching the dev profile or starting the server. + */ +function printHelp() { + console.log(`Usage: node config/scripts/serve-headless-fresh-profile-pairing.mjs [--keep] [orca serve flags] + +Starts orca-dev serve --json with a fresh isolated userData profile, ensures Electron's dev runtime is usable, and prints the pairing URL. + +Wrapper flags: + --keep Keep the fresh profile after the server exits. + -h, --help Show this help. + +Forwarded examples: + node config/scripts/serve-headless-fresh-profile-pairing.mjs --port 6768 + node config/scripts/serve-headless-fresh-profile-pairing.mjs --pairing-address 100.64.1.20 + node config/scripts/serve-headless-fresh-profile-pairing.mjs --mobile-pairing + +Environment: + ORCA_HEADLESS_PAIRING_ADDRESS= Override the auto pairing address. + ORCA_HEADLESS_PAIRING_PROFILE_DIR=/path/to/profile Use a fixed profile directory. +`) +} + +/** + * Adds a reachable pairing address unless the caller provided one explicitly. + */ +function withDefaultPairingAddress(args) { + if (hasPairingAddress(args)) { + return args + } + const address = resolveDefaultPairingAddress() + if (!address) { + return args + } + console.error(`[headless-pairing] pairingAddress=${address} (auto)`) + return [...args, '--pairing-address', address] +} + +/** + * Checks both supported CLI forms for an explicit pairing address. + */ +function hasPairingAddress(args) { + return hasForwardedServeFlag(args, 'pairing-address') +} + +/** + * Checks both exact and `--flag=value` forms for a forwarded serve flag. + */ +function hasForwardedServeFlag(args, name) { + const flag = `--${name}` + return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`)) +} + +/** + * Prefers an override, then Tailscale, then the OS hostname over loopback. + */ +function resolveDefaultPairingAddress() { + const configured = process.env.ORCA_HEADLESS_PAIRING_ADDRESS?.trim() + if (configured) { + return configured + } + const tailscaleAddress = readTailscaleAddress() + if (tailscaleAddress) { + return tailscaleAddress + } + const host = hostname().trim() + return host && host !== 'localhost' ? host : null +} + +/** + * Reads the first Tailscale IPv4 address when Tailscale is installed. + */ +function readTailscaleAddress() { + const result = spawnSync('tailscale', ['ip', '-4'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }) + if (result.error || result.status !== 0) { + return null + } + return ( + result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? null + ) +} + +/** + * Formats forwarded arguments for the startup log. + */ +function formatForwardedArgs(args) { + if (args.length === 0) { + return '' + } + return ` ${args.map(formatShellArg).join(' ')}` +} + +/** + * Quotes a shell-ish argument for display only. + */ +function formatShellArg(value) { + if (/^[\w./:=@+-]+$/.test(value)) { + return value + } + return JSON.stringify(value) +} + +/** + * Runs the same Electron runtime preflight used by dev/start package scripts. + */ +function ensureElectronRuntime() { + console.error('[headless-pairing] ensuring Electron runtime...') + const result = spawnSync(process.execPath, [ensureNativeRuntimeScript, '--runtime=electron'], { + cwd: repoRoot, + stdio: 'inherit' + }) + if (result.error) { + console.error(`[headless-pairing] Electron runtime preflight failed: ${result.error.message}`) + process.exit(1) + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} + +/** + * Rewrites the CLI JSON readiness line into the pairing URL testers need. + */ +function printReadyLine(line) { + let payload + try { + payload = JSON.parse(line) + } catch { + return false + } + if (!payload || payload.type !== 'orca_server_ready') { + return false + } + console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`) + if (payload.pairing?.endpoint) { + console.log(`Pairing endpoint: ${payload.pairing.endpoint}`) + } + if (payload.pairing?.webClientUrl) { + console.log(`Web client URL: ${payload.pairing.webClientUrl}`) + } + if (payload.pairing?.url) { + sawPairingUrl = true + console.log(`Pairing URL: ${payload.pairing.url}`) + console.error('[headless-pairing] server is running; press Ctrl+C to stop.') + return true + } + console.log('Pairing URL: unavailable') + return true +} + +/** + * Stops the foreground server; repeated signals force its process tree down. + */ +function stopChild(signal) { + if (!child || child.killed) { + return + } + stopAttempts += 1 + const targetSignal = stopAttempts > 1 ? 'SIGKILL' : signal + if (process.platform === 'win32' && child.pid) { + // Why: child.kill() only targets orca-dev on Windows; taskkill walks the + // CLI/Electron descendants so the fresh profile is not left locked. + const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + killer.on('error', () => child?.kill(targetSignal)) + return + } + if (process.platform !== 'win32' && child.pid) { + // Why: orca-dev synchronously owns the CLI child, which owns Electron; kill + // the spawned process group so programmatic shutdown does not orphan serve. + try { + process.kill(-child.pid, targetSignal) + return + } catch { + // Fall back to the direct child below if the process group already exited. + } + } + child.kill(targetSignal) +} + +/** + * Removes only the ephemeral profile directory this script created. + */ +function cleanupProfile() { + if (cleanedUp) { + return + } + cleanedUp = true + if (parsed.keepProfile || !ownsProfileDir) { + console.error(`[headless-pairing] kept ${profileDir}`) + return + } + if (!existsSync(profileDir) || !profileDir.includes('orca-headless-pairing-profile-')) { + console.error(`[headless-pairing] skipped cleanup for unexpected profile path: ${profileDir}`) + return + } + try { + rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) + console.error(`[headless-pairing] removed ${profileDir}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[headless-pairing] skipped cleanup for ${profileDir}: ${message}`) + } +} diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 73978e4f3..d4d6031b2 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -265,11 +265,87 @@ describe('resolveTabAgentFromSignals', () => { isRemote: false, title: '✳ Claude Code', hookAgent: null, + launchAgent: 'codex' }) ).toBe('codex') }) + it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: null, + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: '⠋ Pi', + hookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'Terminal 1', + hookAgent: null, + siblingHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '⠋ OMP', + hookAgent: 'omp', + launchAgent: 'pi' + }) + ).toBe('pi') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: 'zsh', + hookAgent: null, + focusedCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'zsh', + hookAgent: null, + siblingCompletedHookAgent: 'pi', + launchAgent: 'omp' + }) + ).toBe('omp') + }) + it('prefers explicit hook identity over a conflicting title mention', () => { expect( resolveTabAgentFromSignals({ diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 40bf92d6f..e6f992fe7 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -11,6 +11,7 @@ import { resolveSiblingTabAgent } from './tab-agent' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' import type { TerminalTab, TuiAgent } from '../../../shared/types' // A shell name, or the tab's neutral default title — where Orca's @@ -20,6 +21,19 @@ function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) } +/** + * Resolves wrapper-compatible signal identity against the launch owner. + */ +function resolveSignalAgentForLaunchOwner( + signalAgent: TuiAgent | null | undefined, + launchAgent: TuiAgent | null +): TuiAgent | null { + if (!signalAgent) { + return null + } + return (resolveCompatibleAgentTypeForOwner(signalAgent, launchAgent) ?? signalAgent) as TuiAgent +} + /** * Probe-free evidence that a launched agent exited: the title shows no agent, * no live hook row remains in the tab, and either the hook completed or @@ -68,11 +82,13 @@ export function resolveTabAgentFromSignals(args: { launchAgent?: TuiAgent }): TuiAgent | null { const launchAgent = args.launchAgent ?? null - const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(args.title) - // Why: when a pane is reused for a different agent, its launchAgent goes stale. - // A live title that explicitly names a *different* agent, once the pane has - // shown any activity, overrides that stale launch identity so the tab icon - // tracks what is actually running (codex launch reused for claude, etc.). + const explicitTitleAgent = resolveSignalAgentForLaunchOwner( + resolveExplicitTerminalTitleAgentType(args.title), + launchAgent + ) + // Why: explicit titles can override stale launches after activity, but + // Pi-compatible wrapper signals first resolve through the launch owner so + // OMP-created sessions do not repaint as Pi. // Why: OSC 133;D proved this local pane's foreground is back at the shell, // so any title-derived identity is stale by definition — a TUI that died // with a stuck title must not keep painting the tab through the title layer. @@ -96,16 +112,20 @@ export function resolveTabAgentFromSignals(args: { const completedHookAgent = !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook ? null - : (args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent ?? null) - const focusedHookAgent = args.hookAgent ?? null - const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null + : resolveSignalAgentForLaunchOwner( + args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent, + launchAgent + ) + const focusedHookAgent = resolveSignalAgentForLaunchOwner(args.hookAgent, launchAgent) + const siblingHookAgent = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) + const fallbackHookAgent = siblingHookAgent ?? completedHookAgent const launchedAgentExited = resolveLaunchedAgentExitEvidence({ title: args.title, defaultTitle: args.defaultTitle, isRemote: args.isRemote, hasObservedAgentSignal: args.hasObservedAgentSignal, hookAgent: focusedHookAgent, - siblingHookAgent: args.siblingHookAgent, + siblingHookAgent, hasCompletedHook, processAgent: args.processAgent, processShellForeground: args.processShellForeground