Fix OMP worktree title identity flicker

* Fix OMP worktree title identity flicker

* Add headless fresh pairing server script

* Harden fresh pairing server script startup

* Default fresh pairing server to reachable address

* review: harden OMP tab identity validation

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Dvitash 2026-07-07 16:51:25 -04:00 committed by GitHub
parent ee6405052a
commit bea9b38544
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 431 additions and 9 deletions

View File

@ -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=<host|host:port|ws://...> 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}`)
}
}

View File

@ -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({

View File

@ -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