diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index eb7f10aa66a..112b690d4ff 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,124 @@ } }, "gates": [ + { + "id": "agent-session.delayed-terminal-launch", + "title": "Delayed terminal launches retain their harness preparation and recover process discovery", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "host-renderer", + "surfaces": [ + "terminal startup commands", + "typed agent commands", + "hidden terminal startup", + "agent hooks and managed accounts" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime", + "wsl" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon" + ], + "coverageNotes": "Shared preparation is exercised with real local plugin files and account environment plus mocked host spawn contracts. Captured real Claude startup title drives renderer recovery. Physical Linux, Windows, SSH and WSL delayed launches remain unproved.", + "motivatingLinks": [ + "docs/bug-reproductions/delayed-agent-launch.md" + ], + "invariant": "A literal sleep followed by && must not cause the later harness to lose hook/account preparation or hidden renderer query delivery. Preparation and typed-command hints are not live process evidence. Fresh agent startup output may request one bounded host confirmation after the initial command read window has expired.", + "oracle": "All 45 configured harnesses keep hidden startup query eligibility. Delayed OpenCode 2 installs v2 hooks rather than v1, disabled harnesses do not fall back, selected managed accounts survive, and Pi/OMP/Prime preparation targets the requested harness. A captured Claude startup title at 30 seconds yields process-confirmed routing, never title-authorized routing. A title storm causes at most three additional reads; close and untrackable providers cause none.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-command-line-intent.test.ts src/shared/pi-agent-kind.test.ts src/shared/opencode-launch-command.test.ts src/main/ipc/pty/host-env/opencode-hook-installation.test.ts src/main/ipc/pty-spawn-env-terminal-basics.test.ts src/main/managed-data-accounts/launch-environment.test.ts src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts --maxWorkers=2" + ], + "testFiles": [ + "src/shared/agent-command-line-intent.test.ts", + "src/shared/pi-agent-kind.test.ts", + "src/shared/opencode-launch-command.test.ts", + "src/main/ipc/pty/host-env/opencode-hook-installation.test.ts", + "src/main/ipc/pty-spawn-env-terminal-basics.test.ts", + "src/main/managed-data-accounts/launch-environment.test.ts", + "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts", + "src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/pty/host-env/opencode-hook-installation.test.ts", + "assertions": [ + "explicit %s with disabled" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts", + "assertions": [ + "keeps hidden %s startup queries live after sleep" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts", + "assertions": [ + "bounds a startup title storm to one extra read ladder and never adopts the title as identity", + "cancels delayed startup discovery on close" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts", + "assertions": [ + "confirms a delayed Claude from fresh startup output after the first read window expires" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-13", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/shared/agent-command-line-intent.test.ts src/shared/pi-agent-kind.test.ts src/shared/opencode-launch-command.test.ts src/main/ipc/pty/host-env/opencode-hook-installation.test.ts src/main/ipc/pty-spawn-env-terminal-basics.test.ts src/main/managed-data-accounts/launch-environment.test.ts src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts --maxWorkers=2", + "result": "passed", + "durationSeconds": 1.86, + "summary": "214 assertions pass across nine files, including all 45 harness classifications, actual hook files, delayed account preparation, late process routing, hidden query delivery and bounded recovery." + } + ], + "runtimeBudget": { + "p95Seconds": 30, + "scope": "Focused deterministic gate; no CI soak established." + }, + "performanceBudget": { + "required": true, + "evidence": "Prefix scanning is capped at 4096 characters; ordinary output has no additional work. Startup title recovery uses one existing three-read ladder per command and joins active reads. Deterministic counts cover storms, close and untrackable providers." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Focused macOS runs passed; no cross-platform soak." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Before the fix, delayed preparation produced 52 failing assertions and the captured late Claude startup title left process routing unset. The corresponding tests passed after the change." + }, + "promotionCriteria": [ + "Collect repeated independent runs and physical provider/platform evidence before promotion." + ], + "knownGaps": [ + "Real Claude hooks completed normally in the original macOS reproduction; no blanket hook outage was reproduced.", + "Literal numeric sleep prefixes only; arbitrary shell scripts, variables, separators, background jobs and pipelines remain unmodeled.", + "Physical Linux, Windows, SSH, WSL and remote-runtime delayed launches and all-harness live CLI runs were not performed.", + "A late agent emitting neither an identifiable startup title nor hook evidence still needs a later visibility/input observation." + ], + "demotionRule": "Keep experimental until soak evidence; investigate failures without widening polling windows, trusting titles or weakening hook/account assertions." + }, { "id": "native-chat.transcript-consumed-boundary", "title": "Transcript replacement remains visible when it races message publication", diff --git a/docs/bug-reproductions/delayed-agent-launch.md b/docs/bug-reproductions/delayed-agent-launch.md new file mode 100644 index 00000000000..0652aacd23c --- /dev/null +++ b/docs/bug-reproductions/delayed-agent-launch.md @@ -0,0 +1,74 @@ +# Delayed terminal agent launches + +## Reproduction (2026-10-12, macOS) + +An isolated Orca dev instance ran off screen with `ORCA_BACKGROUND_LAUNCH=1`. +In a folder workspace, `terminal create --command 'sleep 30 && claude'` launched +real Claude Code 2.1.296 after the delay. SessionStart, working and done hooks +arrived, and a prompt requesting `DELAYED_LAUNCH_OK` completed. A blanket Claude +hook failure was **not** reproduced on this machine. + +The same delayed command was captured through +`config/scripts/capture-agent-pty-transcript.mjs`. Its startup emits +`OSC 0;✳ Claude Code`, terminal capability queries, and the normal composer. +The capture was scanned for identifiers and credentials; raw output is kept +outside the repository. + +Deterministic reproductions found these failures: + +- All 45 configured harnesses lost hidden startup query eligibility behind + `sleep 30 &&`. The renderer query-delivery test also reproduced the drop. +- Delayed OpenCode 2 selected and installed OpenCode v1 hooks. Disabling the + requested version could install the other version instead. +- Delayed Pi, OMP and Prime launches lost explicit preparation of their own + integration. Managed OpenCode/Devin accounts and MiMo overlays also relied on + first-executable classification. +- Once foreground discovery exhausted its initial read window, a captured late + Claude startup title did not restart confirmation. The connection test left + process routing unset at 30 seconds. + +After the fix, a fresh `sleep 30 && claude` launch gained process-confirmed +routing. Real turns running `sleep 8` and `sleep 20` completed with the requested +replies. CDP screenshots showed the working indicator during a turn and the done +indicator with the completed reply. Blocked, waiting and interrupted turns were +not exercised. + +## Change and invariant + +`agent-session.delayed-terminal-launch` in `config/reliability-gates.jsonc` +tracks this contract: a delayed launch retains harness preparation and hidden +terminal replies, while only the execution host can confirm a live process. + +A shared, bounded scanner removes literal numeric `sleep … &&` prefixes **only +from preparation hints**. The shell receives the original command, including +the delay, arguments and conditional operator. Existing setup-sequence hints, +explicit harness choices, disabled integrations and account ownership retain +precedence. Process-table command recognition still treats the waiting shell +as a shell. + +Callers use shared launch-token and launch-recognition APIs. Prefix handling is +contained in `src/shared/agent-command-line-intent.ts`; the setup-sequence resolver +continues to return its supplied command. Launch recognition reuses the existing +agent registry and headless filters without changing strict process recognition. + +Fresh agent startup titles request one additional bounded confirmation sequence +when initial discovery has finished. A title never supplies process identity or +input-routing authority. An active read is preserved; title storms, cancellation +and close are covered by deterministic read-count tests. No recurring timer or +sleep-length polling was added. + +## Coverage and remaining gaps + +| Surface | Evidence | +| --- | --- | +| Local and daemon preparation | Plugin files, account environment and host spawn tests | +| Renderer replies and routing | Hidden query-delivery and captured late-title tests | +| macOS real Claude | Hidden Electron/CDP launch and completed prompt | +| SSH/remote-runtime | Shared classification; existing fenced process reads retained; no physical delayed launch | +| WSL | Shared Pi/OMP and OpenCode preparation; physical guest launch unproved | +| Linux/Windows | Platform-neutral classification and existing mock coverage; physical delayed launch unproved | + +The scanner intentionally does not parse arbitrary shell programs, variables, +pipelines or background jobs. A late agent emitting neither a recognizable title +nor hooks still needs a later input or visibility observation. No RPC field or +stream opcode changed; mixed-version wire behavior is unchanged. diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index c194cf04179..0e157f24280 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -171,16 +171,17 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') expect(env.ORCA_MIMOCODE_SOURCE_HOME).toBeUndefined() }) - it.each(['/usr/local/bin/mimo --prompt hi', '"C:\\Program Files\\MiMo\\mimo.cmd" --prompt hi'])( - 'injects MiMo overlay env for path-qualified launch command %s', - async (launchCommand) => { - const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, launchCommand) + it.each([ + '/usr/local/bin/mimo --prompt hi', + '"C:\\Program Files\\MiMo\\mimo.cmd" --prompt hi', + 'sleep 30 && mimo --prompt hi' + ])('injects MiMo overlay env for path-qualified launch command %s', async (launchCommand) => { + const env = await spawnAndGetEnv(undefined, undefined, undefined, undefined, launchCommand) - expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) - expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') - expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') - } - ) + expect(mimoCodeBuildPtyEnvMock).toHaveBeenCalledTimes(1) + expect(env.MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + expect(env.ORCA_MIMOCODE_HOME).toBe('/tmp/orca-mimocode-shared') + }) it('uses sequenced startup env as the MiMo launch hint when command is a wrapper', async () => { const env = await spawnAndGetEnv( { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'mimo --prompt hi' }, diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 9fc5dbfa94b..e7e4e12b9d0 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -101,6 +101,26 @@ describe('registerPtyHandlers', () => { } ) + it.each(['pi', 'omp', 'prime-agent'] as const)( + 'prepares delayed %s hooks for the requested harness', + (agent) => { + piBuildPtyEnvMock.mockClear() + buildPtyHostEnv( + 'delayed-agent', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: true, + launchCommand: `sleep 30 && ${agent}` + } + ) + expect(piBuildPtyEnvMock.mock.calls.map(([, , kind]) => kind)).toEqual([agent]) + expect(piBuildPtyEnvMock.mock.calls[0]?.[3]).toMatchObject({ materializeDefaultHome: true }) + } + ) + it('does not install managed Pi extensions when Pi is disabled', () => { piBuildPtyEnvMock.mockClear() diff --git a/src/main/ipc/pty/host-env/omp-launch-environment.test.ts b/src/main/ipc/pty/host-env/omp-launch-environment.test.ts index 2eeeadc2c56..f86f9c14c91 100644 --- a/src/main/ipc/pty/host-env/omp-launch-environment.test.ts +++ b/src/main/ipc/pty/host-env/omp-launch-environment.test.ts @@ -20,19 +20,21 @@ beforeEach(() => { afterEach(() => vi.unstubAllGlobals()) describe('OMP launch directory environment', () => { - it.each([{ launchAgent: 'omp' }, { launchCommand: 'omp' }, {}])( - 'inherits login-shell category roots for %j', - async (options) => { - const env = {} - await inheritOmpLaunchEnvironment(env, options) - expect(env).toEqual({ - XDG_DATA_HOME: '/login/data', - XDG_STATE_HOME: '/login/state', - XDG_CACHE_HOME: '/login/cache', - PI_CONFIG_DIR: '.config/omp' - }) - } - ) + it.each([ + { launchAgent: 'omp' }, + { launchCommand: 'omp' }, + { launchCommand: 'sleep 30 && omp' }, + {} + ])('inherits login-shell category roots for %j', async (options) => { + const env = {} + await inheritOmpLaunchEnvironment(env, options) + expect(env).toEqual({ + XDG_DATA_HOME: '/login/data', + XDG_STATE_HOME: '/login/state', + XDG_CACHE_HOME: '/login/cache', + PI_CONFIG_DIR: '.config/omp' + }) + }) it('uses the same login roots for merged local env and daemon pane deltas', async () => { const local = { XDG_DATA_HOME: '/old/data', PI_CONFIG_DIR: '.old-omp' } diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index fe42ca5735c..21605465bb8 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -210,6 +210,7 @@ describe('OpenCode installation uses the current enabled agents', () => { (agent) => { for (const selection of [ { launchCommand: `${agent} --session fixture` }, + { launchCommand: `sleep 30 && ${agent} --session fixture` }, { launchAgent: agent } ]) { const env = buildPtyHostEnv( diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index caf18e3e1aa..f2945940a57 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -1,13 +1,10 @@ -import { - getCommandTokenPathBasename, - getFirstCommandToken -} from '../../../../shared/command-token-scanner' import { PRIMARY_AGENT_DIR_ENV_BY_KIND, SOURCE_AGENT_DIR_ENV_BY_KIND, type PiAgentKind } from '../../../../shared/pi-agent-kind' import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' +import { getAgentLaunchExecutableName } from '../../../../shared/agent-command-line-intent' import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, @@ -157,10 +154,7 @@ export function getInheritedAgentSessionStampEnvKeysToDelete( export { restoreOrStripOverlayEnv } from '../../../../shared/agent-overlay-env' export function isMimoLaunchCommand(launchCommand: string | undefined): boolean { - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) - .toLowerCase() - .replace(/\.(?:cmd|exe|sh)$/, '') - return binary === 'mimo' + return getAgentLaunchExecutableName(launchCommand) === 'mimo' } export function resolveMimocodeSourceHome(baseEnv: Record): string | undefined { diff --git a/src/main/ipc/terminal-git-credential-guard.test.ts b/src/main/ipc/terminal-git-credential-guard.test.ts index 5046fea569b..bf1f7e1da05 100644 --- a/src/main/ipc/terminal-git-credential-guard.test.ts +++ b/src/main/ipc/terminal-git-credential-guard.test.ts @@ -13,6 +13,14 @@ function expectGuarded(env: Record): void { } describe('applyTerminalGitCredentialPromptGuard', () => { + it.each(['win32', 'darwin', 'linux'] as const)('guards a delayed agent on %s', (platform) => { + const env: Record = {} + expect( + applyTerminalGitCredentialPromptGuard(env, { launchCommand: 'sleep 30 && claude', platform }) + ).toBe(true) + expectGuarded(env) + }) + it('guards an agent terminal on every platform', () => { for (const platform of ['win32', 'darwin', 'linux'] as const) { const env: Record = { PATH: '/usr/bin' } diff --git a/src/main/ipc/terminal-startup-color-query-replies.test.ts b/src/main/ipc/terminal-startup-color-query-replies.test.ts index 7b66301cc2d..dcb6d102852 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.test.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.test.ts @@ -59,7 +59,8 @@ it('skips jcode startup colors when only the command or telemetry names it', () for (const launch of [ { command: 'jcode', telemetry: { agent_kind: 'jcode' } }, { telemetry: { agent_kind: 'jcode' } }, - { command: 'jcode' } + { command: 'jcode' }, + { command: 'sleep 30 && jcode' } ]) { expect( getStartupTerminalIngressIntent({ diff --git a/src/main/ipc/terminal-startup-color-query-replies.ts b/src/main/ipc/terminal-startup-color-query-replies.ts index b6060f6be5c..24df05f6f54 100644 --- a/src/main/ipc/terminal-startup-color-query-replies.ts +++ b/src/main/ipc/terminal-startup-color-query-replies.ts @@ -1,6 +1,6 @@ import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress-intent' import { normalizeColorQueryReplyColors } from '../../shared/pty-owner-color-query-colors' -import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { recognizeAgentLaunchCommand } from '../../shared/agent-command-line-intent' import { isTuiAgent } from '../../shared/tui-agent-config' import { agentKindSchema } from '../../shared/telemetry-events' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' @@ -22,7 +22,7 @@ function isAgentLaunch(args: { return true } const command = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' - return recognizeAgentProcessFromCommandLine(command) !== null + return recognizeAgentLaunchCommand(command) !== null } // Why: jcode paints its own theme and fires its OSC 10/11 burst before its TUI @@ -42,7 +42,7 @@ export function agentSkipsStartupOscColorQueryReplies(args: { return true } const command = args.launchConfig?.agentCommand?.trim() || args.command?.trim() || '' - return command.length > 0 && recognizeAgentProcessFromCommandLine(command)?.agent === 'jcode' + return command.length > 0 && recognizeAgentLaunchCommand(command)?.agent === 'jcode' } export function getStartupTerminalIngressIntent(args: { diff --git a/src/main/managed-data-accounts/launch-environment.test.ts b/src/main/managed-data-accounts/launch-environment.test.ts index be6be25b175..0b07b4d5103 100644 --- a/src/main/managed-data-accounts/launch-environment.test.ts +++ b/src/main/managed-data-accounts/launch-environment.test.ts @@ -47,6 +47,17 @@ afterEach(() => { }) describe('managed account inherited environment', () => { + it.each(['opencode', 'opencode2', 'devin'])( + 'keeps the selected account for delayed %s', + (agent) => { + selected.value = { XDG_DATA_HOME: join(tmpdir(), 'selected-account', 'data') } + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchCommand: `sleep 30 && ${agent}` }) + expect(env.XDG_DATA_HOME).toBe(selected.value.XDG_DATA_HOME) + expect(env.ORCA_DATA_ACCOUNT_PROVIDER).toBe(agent === 'devin' ? 'devin' : 'opencode') + } + ) + it.each([ { launchAgent: 'opencode' as const }, { launchAgent: 'claude' as const }, diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts index 51a44e64e26..0881bec1d2d 100644 --- a/src/main/managed-data-accounts/launch-environment.ts +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -1,10 +1,7 @@ -import { - getCommandTokenPathBasename, - getFirstCommandToken -} from '../../shared/command-token-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { getManagedDataAccountService } from './service' import { MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS } from '../../shared/managed-data-account-environment' +import { getAgentLaunchExecutableName } from '../../shared/agent-command-line-intent' export function applyManagedDataAccountEnvironment( environment: Record, @@ -23,12 +20,7 @@ export function applyManagedDataAccountEnvironment( if (options.isWsl) { return } - const agent = - options.launchAgent ?? - getCommandTokenPathBasename(getFirstCommandToken(options.launchCommand ?? '')).replace( - /\.(?:exe|cmd|sh)$/i, - '' - ) + const agent = options.launchAgent ?? getAgentLaunchExecutableName(options.launchCommand) const provider = agent === 'opencode' || agent === 'opencode2' ? 'opencode' : agent === 'devin' ? 'devin' : null if (!provider) { diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 12f6a8fec64..649905a7a0a 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -38,6 +38,9 @@ describe('OpenCode execution-host launch capability probe', () => { }) it('recognizes quoted executables and preserves explicit run commands', () => { expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('sleep 30 && "/app dir/opencode2"')).toBe( + '/app dir/opencode2' + ) expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( 'custom-launcher' ) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index ee056ee9d4b..2f932e88b15 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -1,8 +1,4 @@ import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' -import { - getFirstCommandToken, - getCommandTokenPathBasename -} from '../../shared/command-token-scanner' import { getOpenCodeCliCapabilities, type OpenCodeCliCapabilities @@ -11,15 +7,17 @@ import type { TuiAgent } from '../../shared/tui-agent' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' import { runWslProcess } from '../wsl/wsl-runner' import { probeOpenCodeCliVersion } from './opencode-cli-version' +import { + getAgentLaunchCommandToken, + getAgentLaunchExecutableName +} from '../../shared/agent-command-line-intent' export function getOpenCodeLaunchExecutable( command: string | undefined, agent?: TuiAgent ): string | null { - const executable = getFirstCommandToken(command ?? '') - const name = getCommandTokenPathBasename(executable) - .toLowerCase() - .replace(/\.(?:exe|cmd|sh)$/, '') + const executable = getAgentLaunchCommandToken(command) + const name = getAgentLaunchExecutableName(command) return agent === 'opencode' || agent === 'opencode2' || (!agent && (name === 'opencode' || name === 'opencode2')) @@ -87,7 +85,10 @@ export async function probeOpenCodeLaunchCapabilities(options: { } const executablePath = options.resolveExecutable ? await options.resolveExecutable(executable) - : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + : await resolveCommandOnLocalPath(executable, { + env: options.env, + cwd: options.cwd + }) return executablePath ? probeOpenCodeCliVersion({ executablePath, diff --git a/src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts b/src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts new file mode 100644 index 00000000000..35a79ada335 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-delayed-agent-startup.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +function fixture(trackable = true) { + let foreground: string | null = 'sleep' + const read = vi.fn(async () => foreground) + const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-delayed', + isTrackablePtyId: () => trackable, + readForegroundProcess: read, + confirmForegroundProcess: read, + publish + }) + return { + tracker, + read, + publish, + foreground: (value: string | null) => { + foreground = value + } + } +} + +it('confirms the real process when startup arrives after the command read window', async () => { + const f = fixture() + f.tracker.onCommandStarted() + await vi.advanceTimersByTimeAsync(30_000) + expect(f.read).toHaveBeenCalledTimes(3) + f.foreground('claude') + f.tracker.onAgentStartupObserved() + await vi.advanceTimersByTimeAsync(350) + expect(f.publish).toHaveBeenLastCalledWith({ + agent: 'claude', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + }) + expect(f.read).toHaveBeenCalledTimes(4) + f.tracker.dispose() +}) + +it('bounds a startup title storm to one extra read ladder and never adopts the title as identity', async () => { + const f = fixture() + for (let i = 0; i < 100; i += 1) { + f.tracker.onAgentStartupObserved() + } + await vi.advanceTimersByTimeAsync(30_000) + for (let i = 0; i < 100; i += 1) { + f.tracker.onAgentStartupObserved() + } + await vi.advanceTimersByTimeAsync(60_000) + expect(f.read).toHaveBeenCalledTimes(3) + expect(f.publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: false }) + f.tracker.dispose() +}) + +it('does not cancel an in-flight command confirmation on a startup title', async () => { + const f = fixture() + f.tracker.onCommandStarted() + f.tracker.onAgentStartupObserved() + f.foreground('codex') + await vi.advanceTimersByTimeAsync(350) + expect(f.read).toHaveBeenCalledOnce() + expect(f.publish).toHaveBeenLastCalledWith(expect.objectContaining({ agent: 'codex' })) + f.tracker.dispose() +}) + +it('cancels delayed startup discovery on close', async () => { + const f = fixture() + f.tracker.onAgentStartupObserved() + f.tracker.dispose() + await vi.advanceTimersByTimeAsync(30_000) + f.tracker.onAgentStartupObserved() + expect(f.read).not.toHaveBeenCalled() +}) + +it('leaves untrackable providers free of client process scans', async () => { + const f = fixture(false) + f.tracker.onAgentStartupObserved() + await vi.advanceTimersByTimeAsync(30_000) + expect(f.read).not.toHaveBeenCalled() + expect(f.publish).not.toHaveBeenCalled() + f.tracker.dispose() +}) + +it('keeps a legacy remote process hint unverifiable after a startup title', async () => { + const read = vi.fn(async () => 'claude') + const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'ssh:connection@@pty-delayed', + isTrackablePtyId: () => true, + isRemotePtyId: () => true, + getExpectedIncarnationId: () => 'inc-current', + readForegroundProcess: read, + confirmForegroundProcess: read, + publish + }) + tracker.onAgentStartupObserved() + await vi.advanceTimersByTimeAsync(30_000) + expect(read).toHaveBeenCalledTimes(3) + expect(read).toHaveBeenCalledWith('ssh:connection@@pty-delayed', { + expectedIncarnationId: 'inc-current' + }) + expect(publish).not.toHaveBeenCalled() + tracker.dispose() +}) + +it('allows the next command to recover after a prior startup miss', async () => { + const f = fixture() + f.tracker.onAgentStartupObserved() + await vi.advanceTimersByTimeAsync(30_000) + f.tracker.onCommandFinished() + f.tracker.onCommandStarted() + await vi.advanceTimersByTimeAsync(30_000) + f.foreground('gemini') + f.tracker.onAgentStartupObserved() + await vi.advanceTimersByTimeAsync(350) + expect(f.publish).toHaveBeenLastCalledWith(expect.objectContaining({ agent: 'gemini' })) + f.tracker.dispose() +}) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts new file mode 100644 index 00000000000..de510593e5d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker-contract.ts @@ -0,0 +1,39 @@ +import type { RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' + +export type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' + +export type PaneForegroundAgentTrackerDeps = { + getPtyId: () => string | null + /** Local panes only — remote/SSH foreground reads are expensive RPCs and + * their replayed OSC streams must not produce process evidence. */ + isTrackablePtyId: (ptyId: string) => boolean + readForegroundProcess: ( + ptyId: string, + options?: { expectedIncarnationId?: string } + ) => Promise + /** Fresh, provider-owned evidence used only when input routing may change. */ + confirmForegroundProcess?: ( + ptyId: string, + options?: { expectedIncarnationId?: string } + ) => Promise + /** Remote authorities must provide fenced evidence; local panes retain the string path. */ + isRemotePtyId?: (ptyId: string) => boolean + getExpectedIncarnationId?: () => string | null + publish: (entry: PaneForegroundAgentEntry) => void + /** The entry currently published for this pane, whoever wrote it. */ + getPublishedEntry?: () => PaneForegroundAgentEntry | undefined + /** A local read recognized an agent; lets the pane's process monitor watch for its exit. */ + onAgentProcessRead?: (process: RecognizedAgentProcess) => void + /** True when the pane is otherwise known to run an agent (launchAgent, live + * hook status). Lets a restored agent pane confirm — rather than trust — a + * 133;D before any command-start read has recorded its own evidence. */ + hasKnownAgentIdentity?: () => boolean + /** Fired when a confirming read proves the foreground genuinely returned to a + * shell (agent exited). Lets callers clear a stale agent-named tab title that + * the shell never repaints. */ + onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished' | 'process-exit') => void + onCommandFinishedUnavailable?: () => void + onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void +} diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 446afdd1f1b..5dee38848e7 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -6,47 +6,14 @@ import { import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' import { isShellProcess } from '../../../../shared/shell-process-detection' import type { TerminalAgent } from '../../../../shared/terminal-agent' -import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' -import type { RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { createPaneForegroundProcessReader } from './pane-foreground-process-reader' import { FOREGROUND_COMMAND_READS } from '../../../../shared/foreground-command-settle' +import type { + ForegroundReadReason, + PaneForegroundAgentTrackerDeps +} from './pane-foreground-agent-tracker-contract' const VISIBLE_PTY_SETTLE_MS = 350 -type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' - -type PaneForegroundAgentTrackerDeps = { - getPtyId: () => string | null - /** Local panes only — remote/SSH foreground reads are expensive RPCs and - * their replayed OSC streams must not produce process evidence. */ - isTrackablePtyId: (ptyId: string) => boolean - readForegroundProcess: ( - ptyId: string, - options?: { expectedIncarnationId?: string } - ) => Promise - /** Fresh, provider-owned evidence used only when input routing may change. */ - confirmForegroundProcess?: ( - ptyId: string, - options?: { expectedIncarnationId?: string } - ) => Promise - /** Remote authorities must provide fenced evidence; local panes retain the string path. */ - isRemotePtyId?: (ptyId: string) => boolean - getExpectedIncarnationId?: () => string | null - publish: (entry: PaneForegroundAgentEntry) => void - /** The entry currently published for this pane, whoever wrote it. */ - getPublishedEntry?: () => PaneForegroundAgentEntry | undefined - /** A local read recognized an agent; lets the pane's process monitor watch for its exit. */ - onAgentProcessRead?: (process: RecognizedAgentProcess) => void - /** True when the pane is otherwise known to run an agent (launchAgent, live - * hook status). Lets a restored agent pane confirm — rather than trust — a - * 133;D before any command-start read has recorded its own evidence. */ - hasKnownAgentIdentity?: () => boolean - /** Fired when a confirming read proves the foreground genuinely returned to a - * shell (agent exited). Lets callers clear a stale agent-named tab title that - * the shell never repaints. */ - onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished' | 'process-exit') => void - onCommandFinishedUnavailable?: () => void - onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void -} /** * Publishes process-table identity for a pane at OSC 133 command boundaries: @@ -61,6 +28,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke hasReadInFlight: () => boolean onVisiblePtyBound: (expectsAgent?: boolean) => boolean onCommandStarted: (expectedAgent?: TerminalAgent | null) => void + onAgentStartupObserved: () => void /** True when pane identity must remain visible until an async shell confirmation. */ onCommandFinished: () => boolean /** The process monitor confirmed this agent exited (no agent, no children, settled). */ @@ -80,6 +48,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // cannot remove the identity that authorizes the bounded retry ladder. let hasKnownAgentEvidence = false let hasAgentExpectation = false + let startupSampled = false const readProcess = createPaneForegroundProcessReader(deps) const clearAgentEvidence = (): void => { hasForegroundAgentEvidence = false @@ -300,6 +269,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke return true }, onCommandStarted(expectedAgent = null) { + startupSampled = false const hadReadBeforeCommandStart = hasPendingRead() cancelPendingRead() const ptyId = trackablePtyId() @@ -321,7 +291,24 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } scheduleRead(FOREGROUND_COMMAND_READS.settleMs, 0, 'command') }, + onAgentStartupObserved() { + if ( + disposed || + startupSampled || + hasForegroundAgentEvidence || + hasPendingRead() || + !trackablePtyId() + ) { + return + } + // A late startup title requests one bounded confirmation; it never supplies identity. + startupSampled = true + cancelPendingRead() + hasAgentExpectation = true + scheduleRead(FOREGROUND_COMMAND_READS.settleMs, 0, 'command') + }, onCommandFinished() { + startupSampled = false if (deps.hasKnownAgentIdentity?.() === true) { hasKnownAgentEvidence = true } @@ -370,6 +357,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const hadVisibleRead = hasPendingRead() cancelPendingRead() clearAgentEvidence() + startupSampled = false // Why not shellForeground: a pane without command marks gets no command start to lift that // latch, so it would block the Enter sample that identifies the next agent typed there. deps.publish({ agent: null, shellForeground: false }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts index 5954b164b60..144c1c23dc2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts @@ -453,6 +453,43 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('csi-u') }) + it('confirms a delayed Claude from fresh startup output after the first read window expires', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + let foreground = 'sleep' + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) + const tabId = 'tab-delayed-claude' + const ptyId = 'pty-delayed-claude' + const paneKey = makePaneKey(tabId, LEAF_1) + transportFactoryQueue.push(createMockTransport(ptyId)) + const binding = connectPanePty( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The pane fixture implements the terminal APIs exercised by this connection test. + createPane(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The manager fixture supplies this single pane and its connection callbacks. + createManager(1) as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection dependency fixture supplies all APIs used by the test with the explicit startup override. + createDeps({ tabId, startup: { command: 'sleep 30 && claude' } }) as never + ) + await vi.advanceTimersByTimeAsync(30_000) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]?.agent).not.toBe('claude') + + foreground = 'claude' + const onTitleChange = createdTransportOptions[0]?.onTitleChange + if (typeof onTitleChange !== 'function') { + throw new Error('Missing live title consumer') + } + // Captured with capture-agent-pty-transcript.mjs from a real delayed Claude launch. + onTitleChange('✳ Claude Code', '✳ Claude Code') + await vi.advanceTimersByTimeAsync(350) + expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toMatchObject({ + agent: 'claude', + agentEvidence: 'process-read', + routingTrusted: true + }) + binding.dispose() + }) + it('revokes trusted Droid after accepted no-OSC exit input until shell confirmation', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts index b1a2b7b5fa6..449e72ed704 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-codex-queries.test.ts @@ -188,34 +188,40 @@ describe('connectPanePty', () => { binding.dispose() }) - it('keeps hidden Codex terminal query chunks on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) + it.each(['codex', 'sleep 30 && codex', 'sleep 30 && claude'])( + 'keeps hidden %s terminal query chunks on the live xterm path', + async (command) => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + } + ) + transportFactoryQueue.push(transport) - const pane = createPane(1) - const manager = createManager(1) - const binding = connectPanePty( - pane as never, - manager as never, - createDeps({ - isVisibleRef: { current: false }, - startup: { command: 'codex' } - }) as never - ) - await flushAsyncTicks(6) + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The connection fixture supplies the hidden pane APIs with this explicit startup command. + createDeps({ + isVisibleRef: { current: false }, + startup: { command } + }) as never + ) + await flushAsyncTicks(6) - capturedDataCallback.current?.('\x1b[c') + capturedDataCallback.current?.('\x1b[c') - expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function)) - binding.dispose() - }) + binding.dispose() + } + ) it('keeps only coalesced hidden Codex terminal queries on the live xterm path', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts index a1663e5f1ed..d4af86925db 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts @@ -9,7 +9,7 @@ import { registerAgentHookTerminalLifecycleHandler } from '../agent-hook-termina import type { AgentCompletionStatusSnapshot } from '../agent-completion-coordinator-types' import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' -import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition' +import { recognizeAgentLaunchCommand } from '../../../../../shared/agent-command-line-intent' import type { TerminalAgent } from '../../../../../shared/terminal-agent' import { isTuiAgent } from '../../../../../shared/tui-agent-config' @@ -36,7 +36,7 @@ export function installCommandInferredPaneAgent(session: ConnectPanePtySession): const commandLine = session.pendingShellCommandLine.trim() session.resetPendingShellCommandLine() const candidateAgent = commandLine - ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) + ? (recognizeAgentLaunchCommand(commandLine)?.agent ?? null) : null const state = useAppStore.getState() const registeredLaunchAgent = diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts index 815d9de0c4f..a88d547cf3c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { COMMAND_TOKEN_SCAN_MAX_CHARS } from '../../../../../shared/command-token-scanner' import { paneShouldAnswerOscColorQueries, resolvePaneLaunchAgentCandidate, @@ -18,6 +19,36 @@ function store(overrides: Partial = {}): PaneLaunchAg } describe('pane launch-agent candidate', () => { + it('keeps command-only pane inference bounded for large pasted scripts', () => { + const command = `sleep 30 && claude '${'x'.repeat(COMMAND_TOKEN_SCAN_MAX_CHARS)}'` + expect(resolvePaneLaunchTuiAgent(store(), { ...PANE, startup: { command } })).toBeNull() + expect( + resolvePaneLaunchTuiAgent(store(), { ...PANE, startup: { command, launchAgent: 'claude' } }) + ).toBe('claude') + }) + + it.each(['jcode', 'sleep 30 && jcode'])( + 'skips jcode colors for command-only startup %s', + (command) => { + const pane = { ...PANE, startup: { command } } + expect(resolvePaneLaunchTuiAgent(store(), pane)).toBe('jcode') + expect(paneShouldAnswerOscColorQueries(store(), pane)).toBe(false) + } + ) + + it('keeps explicit pane ownership ahead of a delayed command hint', () => { + const pane = { ...PANE, startup: { command: 'sleep 30 && jcode', launchAgent: 'claude' } } + expect(resolvePaneLaunchTuiAgent(store(), pane)).toBe('claude') + expect(paneShouldAnswerOscColorQueries(store(), pane)).toBe(true) + }) + + it.each(['echo "sleep 30 && jcode"', 'sleep 30 && claude -p task'])( + 'does not infer a TUI from %s', + (command) => { + expect(resolvePaneLaunchTuiAgent(store(), { ...PANE, startup: { command } })).toBeNull() + } + ) + it('survives a launch config registered before its identity lands', () => { // Why: the OSC color-reply skip made this ladder run on EVERY pane connect, and // reading through a missing `identity` threw "Cannot read properties of undefined diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts index de997902a46..0b0aae0d367 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-launch-agent-candidate.ts @@ -1,5 +1,7 @@ import { isTuiAgent } from '../../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../../shared/tui-agent' +import { recognizeAgentLaunchCommand } from '../../../../../shared/agent-command-line-intent' +import { COMMAND_TOKEN_SCAN_MAX_CHARS } from '../../../../../shared/command-token-scanner' /** The store slice the launch-agent ladder reads. */ export type PaneLaunchAgentStoreSlice = { @@ -11,7 +13,7 @@ export type PaneLaunchAgentPaneSlice = { worktreeId: string tabId: string paneKey: string - startup?: { launchAgent?: string; initialAgentStatus?: { agent?: string } } + startup?: { command?: string; launchAgent?: string; initialAgentStatus?: { agent?: string } } } /** @@ -28,11 +30,15 @@ export function resolvePaneLaunchAgentCandidate( pane: PaneLaunchAgentPaneSlice ): string | undefined { const registered = state.agentLaunchConfigByPaneKey[pane.paneKey]?.identity?.agentType + const command = pane.startup?.command return ( state.tabsByWorktree[pane.worktreeId]?.find((tab) => tab.id === pane.tabId)?.launchAgent ?? pane.startup?.launchAgent ?? pane.startup?.initialAgentStatus?.agent ?? - (isTuiAgent(registered) ? registered : undefined) + (isTuiAgent(registered) ? registered : undefined) ?? + (command && command.length <= COMMAND_TOKEN_SCAN_MAX_CHARS + ? recognizeAgentLaunchCommand(command)?.agent + : undefined) ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts index 7a0700d1c32..f4d73993fb8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts @@ -9,6 +9,7 @@ import { import { canCommandCodeOutputOwnPane } from '../command-code-output-ownership' import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' import { rendererAgentStatusObservations } from '@/lib/renderer-agent-status-observations' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -20,6 +21,9 @@ export function installTitleSpawnBell(session: ConnectPanePtySession): void { rawTitle: string, meta?: { staleWorkingTitleClear?: boolean } ): void => { + if (!meta?.staleWorkingTitleClear && resolveCommittedTitleAgentType(rawTitle)) { + session.paneForegroundAgentTracker.onAgentStartupObserved() + } // Why: one owner-aware decision drives the display label, the runtime/tab // title, task-completion tracking, and the renderer gate, so raw title text // can no longer disable GPU behind stronger owner evidence (#7428/#7447). diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts index 1e74418a607..b5636c5704f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { shouldKeepHiddenStartupRendererQueriesLive } from './pty-connection/hidden-startup-renderer-query' import { isKnownTuiAgentTerminalStartupCommand, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS @@ -15,6 +17,16 @@ function getRegexWhitespaceSplitCalls(split: ReturnType): unkno } describe('terminal startup command classifier', () => { + it.each(Object.entries(TUI_AGENT_CONFIG))( + 'keeps hidden %s startup queries live after sleep', + (_agent, config) => { + expect(isKnownTuiAgentTerminalStartupCommand(config.detectCmd)).toBe(true) + const command = `sleep 30 && ${config.detectCmd}` + expect(isKnownTuiAgentTerminalStartupCommand(command)).toBe(true) + expect(shouldKeepHiddenStartupRendererQueriesLive({ command })).toBe(true) + } + ) + it('extracts the first startup command token without regex whitespace splitting', () => { const split = vi.spyOn(String.prototype, 'split') const command = [' ', String.fromCharCode(160), 'codex\t--ask'].join('') diff --git a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts index 45cf38dfea8..230b715ca63 100644 --- a/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts +++ b/src/renderer/src/components/terminal-pane/terminal-startup-command-classifier.ts @@ -1,8 +1,13 @@ import { getTuiAgentDetectCommands, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { + COMMAND_TOKEN_SCAN_MAX_CHARS, + getCommandTokenPathBasename +} from '../../../../shared/command-token-scanner' +import { getAgentLaunchCommandToken } from '../../../../shared/agent-command-line-intent' const TERMINAL_STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i // Why: startup commands can carry pasted scripts; classifier work should stay bounded. -export const TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS = 4096 +export const TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS = COMMAND_TOKEN_SCAN_MAX_CHARS const KNOWN_TUI_AGENT_EXECUTABLES = new Set() @@ -19,37 +24,6 @@ for (const config of Object.values(TUI_AGENT_CONFIG)) { } } -function getTerminalStartupCommandToken(command: string): string { - const scanLimit = Math.min(command.length, TERMINAL_STARTUP_COMMAND_TOKEN_MAX_CHARS) - let index = 0 - - while (index < scanLimit && isTerminalStartupCommandWhitespace(command.charCodeAt(index))) { - index += 1 - } - if (index >= scanLimit) { - return '' - } - - const quote = command[index] - if ((quote === '"' || quote === "'") && index + 1 < scanLimit) { - const quotedTokenStart = index + 1 - for (let end = quotedTokenStart; end < scanLimit; end += 1) { - if (command[end] === quote) { - if (end > quotedTokenStart) { - return command.slice(quotedTokenStart, end) - } - break - } - } - } - - const tokenStart = index - while (index < scanLimit && !isTerminalStartupCommandWhitespace(command.charCodeAt(index))) { - index += 1 - } - return command.slice(tokenStart, index) -} - export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean { const executable = getTerminalStartupCommandExecutableName(command) return ( @@ -61,37 +35,10 @@ export function isKnownTuiAgentTerminalStartupCommand(command: string): boolean } function getTerminalStartupCommandExecutableName(command: string): string { - const token = getTerminalStartupCommandToken(command) - const segmentStart = getTerminalStartupCommandPathSegmentStart(token) - return normalizeTerminalStartupCommandExecutableName(token.slice(segmentStart)) + const token = getAgentLaunchCommandToken(command) + return normalizeTerminalStartupCommandExecutableName(getCommandTokenPathBasename(token)) } function normalizeTerminalStartupCommandExecutableName(executable: string): string { return executable.toLowerCase().replace(TERMINAL_STARTUP_COMMAND_EXTENSION_RE, '') } - -function getTerminalStartupCommandPathSegmentStart(token: string): number { - for (let index = token.length - 1; index >= 0; index -= 1) { - const code = token.charCodeAt(index) - if (code === 47 || code === 92) { - return index + 1 - } - } - return 0 -} - -function isTerminalStartupCommandWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} diff --git a/src/shared/agent-command-line-intent.test.ts b/src/shared/agent-command-line-intent.test.ts new file mode 100644 index 00000000000..0ffd11bd549 --- /dev/null +++ b/src/shared/agent-command-line-intent.test.ts @@ -0,0 +1,74 @@ +import { expect, it } from 'vitest' +import { + extractAgentLaunchCommand, + getAgentLaunchCommandToken, + getAgentLaunchExecutableName, + recognizeAgentLaunchCommand +} from './agent-command-line-intent' +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import { COMMAND_TOKEN_SCAN_MAX_CHARS } from './command-token-scanner' + +it.each([ + ['sleep 30 && claude', 'claude'], + [' sleep 30&&codex "do not sleep 30 && claude"', 'codex "do not sleep 30 && claude"'], + [ + '/bin/sleep .5 && /usr/bin/sleep 1m && "C:\\Program Files\\Orca\\codex.exe"', + '"C:\\Program Files\\Orca\\codex.exe"' + ] +])('extracts only the delayed launch hint from %s', (command, expected) => { + expect(extractAgentLaunchCommand(command)).toBe(expected) +}) + +it.each([ + 'echo "sleep 30 && claude"', + 'sleep 30 || claude', + 'sleep 30 | claude', + 'sleep 30 & claude', + 'sleep "$DELAY" && claude', + 'sleep 30; claude', + 'sleep-helper 30 && claude', + 'claude "sleep 30 && codex"', + 'sh -c "sleep 30 && claude"' +])('leaves unmodeled shell syntax intact: %s', (command) => { + expect(extractAgentLaunchCommand(command)).toBe(command) +}) + +it('bounds prefix scanning without truncating the launch arguments', () => { + const task = 'x'.repeat(COMMAND_TOKEN_SCAN_MAX_CHARS * 4) + expect(extractAgentLaunchCommand(`sleep 30 && claude '${task}'`)).toBe(`claude '${task}'`) + const command = `${' '.repeat(COMMAND_TOKEN_SCAN_MAX_CHARS)}sleep 30 && claude` + expect(extractAgentLaunchCommand(command)).toBe(command) +}) + +it('does not turn a waiting shell process into live agent evidence', () => { + expect(recognizeAgentLaunchCommand('sleep 30 && claude')?.agent).toBe('claude') + expect(recognizeAgentProcessFromCommandLine('sleep 30 && claude')).toBeNull() + expect(recognizeAgentProcessFromCommandLine('/bin/zsh -c "sleep 30 && claude"')).toBeNull() +}) + +it('extracts executable tokens through the shared launch boundary', () => { + expect( + getAgentLaunchCommandToken('sleep 30 && "C:\\Program Files\\Orca\\codex.exe" --resume') + ).toBe('C:\\Program Files\\Orca\\codex.exe') + expect(getAgentLaunchCommandToken(undefined)).toBe('') + expect(getAgentLaunchCommandToken('echo "sleep 30 && claude"')).toBe('echo') +}) + +it.each([ + ['sleep 30 && "C:\\Program Files\\Orca\\Codex.EXE" --resume', 'codex'], + ['/usr/local/bin/mimo.sh', 'mimo'], + ['sleep 1 && opencode2.cmd', 'opencode2'], + [undefined, ''] +])('normalizes the launch executable name of %s', (command, expected) => { + expect(getAgentLaunchExecutableName(command)).toBe(expected) +}) + +it('preserves headless filtering and interpreter recognition for launch intent', () => { + expect(recognizeAgentLaunchCommand('sleep 30 && claude -p task')).toBeNull() + expect( + recognizeAgentLaunchCommand('sleep 30 && claude -p task', { + includeHeadlessOneShot: true + })?.agent + ).toBe('claude') + expect(recognizeAgentLaunchCommand('sleep 30 && node /usr/local/bin/codex')?.agent).toBe('codex') +}) diff --git a/src/shared/agent-command-line-intent.ts b/src/shared/agent-command-line-intent.ts new file mode 100644 index 00000000000..6aac4cbd9cd --- /dev/null +++ b/src/shared/agent-command-line-intent.ts @@ -0,0 +1,45 @@ +import { + COMMAND_TOKEN_SCAN_MAX_CHARS, + getCommandTokenPathBasename, + getFirstCommandToken +} from './command-token-scanner' +import { + recognizeAgentProcessFromCommandLine, + type RecognizedAgentProcess +} from './agent-process-recognition' + +const SLEEP_PREFIX = + /^[ \t]*(?:sleep|\/bin\/sleep|\/usr\/bin\/sleep)[ \t]+(?:\d+(?:\.\d+)?|\.\d+)[smhd]?[ \t]*&&[ \t]*/ + +/** Launch preparation hint only; the shell still receives the complete command. */ +export function extractAgentLaunchCommand(command: string): string { + const prefix = command.slice(0, COMMAND_TOKEN_SCAN_MAX_CHARS) + let offset = 0 + while (offset < prefix.length) { + const match = SLEEP_PREFIX.exec(prefix.slice(offset)) + if (!match) { + break + } + offset += match[0].length + } + return command.slice(offset) +} + +export function getAgentLaunchCommandToken(command: string | null | undefined): string { + return getFirstCommandToken(extractAgentLaunchCommand(command ?? '')) +} + +/** Lowercased executable basename without a Windows/shell launcher extension. */ +export function getAgentLaunchExecutableName(command: string | null | undefined): string { + return getCommandTokenPathBasename(getAgentLaunchCommandToken(command)) + .toLowerCase() + .replace(/\.(?:exe|cmd|bat|ps1|sh)$/, '') +} + +/** Launch intent may include shell prefixes; process-table readers use the strict matcher. */ +export function recognizeAgentLaunchCommand( + commandLine: string | null | undefined, + options?: Parameters[1] +): RecognizedAgentProcess | null { + return recognizeAgentProcessFromCommandLine(extractAgentLaunchCommand(commandLine ?? ''), options) +} diff --git a/src/shared/opencode-launch-command.test.ts b/src/shared/opencode-launch-command.test.ts index d9457de42e0..dc30a529003 100644 --- a/src/shared/opencode-launch-command.test.ts +++ b/src/shared/opencode-launch-command.test.ts @@ -29,3 +29,14 @@ describe('OpenCode hook selection', () => { ).toBe('opencode2') }) }) + +describe('selectOpenCodeHookAgent after a sleep prefix', () => { + it('selects OpenCode 2 hooks instead of the v1 fallback', () => { + expect(selectOpenCodeHookAgent(undefined, 'sleep 30 && opencode2', () => true)).toBe( + 'opencode2' + ) + expect( + selectOpenCodeHookAgent(undefined, 'sleep 30 && opencode2', (agent) => agent === 'opencode') + ).toBeNull() + }) +}) diff --git a/src/shared/opencode-launch-command.ts b/src/shared/opencode-launch-command.ts index 92c4896b094..86c39837081 100644 --- a/src/shared/opencode-launch-command.ts +++ b/src/shared/opencode-launch-command.ts @@ -1,10 +1,8 @@ -import { getCommandTokenPathBasename, getFirstCommandToken } from './command-token-scanner' import type { TuiAgent } from './tui-agent' +import { getAgentLaunchExecutableName } from './agent-command-line-intent' function openCodeCommandAgent(launchCommand: string | undefined): 'opencode' | 'opencode2' | null { - const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) - .toLowerCase() - .replace(/\.(?:cmd|exe|sh)$/, '') + const binary = getAgentLaunchExecutableName(launchCommand) return binary === 'opencode' || binary === 'opencode2' ? binary : null } diff --git a/src/shared/pi-agent-kind.test.ts b/src/shared/pi-agent-kind.test.ts index 2285855c6a3..f494df8b4fa 100644 --- a/src/shared/pi-agent-kind.test.ts +++ b/src/shared/pi-agent-kind.test.ts @@ -84,3 +84,9 @@ describe('detectExplicitPiAgentKindFromCommand', () => { ) }) }) + +describe('detectExplicitPiAgentKindFromCommand after a sleep prefix', () => { + it.each(['pi', 'omp', 'prime-agent'] as const)('prepares the %s integration', (agent) => { + expect(detectExplicitPiAgentKindFromCommand(`sleep 30 && ${agent}`)).toBe(agent) + }) +}) diff --git a/src/shared/pi-agent-kind.ts b/src/shared/pi-agent-kind.ts index ec645fc4d67..17fa68f60bc 100644 --- a/src/shared/pi-agent-kind.ts +++ b/src/shared/pi-agent-kind.ts @@ -1,6 +1,7 @@ import { isFreshOmpLaunchCommand } from './omp-fresh-launch' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { getCommandTokenPathBasename, getFirstCommandToken } from './command-token-scanner' +import { extractAgentLaunchCommand } from './agent-command-line-intent' /** * Pi-compatible agent kinds. Both Pi and OMP (omp.sh) consume the same @@ -50,10 +51,11 @@ const PRIME_AGENT_LAUNCH_BINARY = getLaunchBinary(TUI_AGENT_CONFIG['prime-agent' export function detectExplicitPiAgentKindFromCommand( command: string | undefined ): PiAgentKind | null { - if (isFreshOmpLaunchCommand(command)) { + const launchCommand = extractAgentLaunchCommand(command ?? '') + if (isFreshOmpLaunchCommand(launchCommand)) { return 'omp' } - const binary = getLaunchBinary(command ?? '') + const binary = getLaunchBinary(launchCommand) if (binary === OMP_LAUNCH_BINARY) { return 'omp' } diff --git a/src/shared/terminal-git-credential-guard.ts b/src/shared/terminal-git-credential-guard.ts index 340e2cdc7cf..7cc9aaaedf7 100644 --- a/src/shared/terminal-git-credential-guard.ts +++ b/src/shared/terminal-git-credential-guard.ts @@ -1,4 +1,4 @@ -import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import { recognizeAgentLaunchCommand } from './agent-command-line-intent' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' const GIT_CONFIG_PROTOCOL_KEY_RE = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/ @@ -23,7 +23,9 @@ export function applyTerminalGitCredentialPromptGuard( const shouldGuard = opts.isUnattended === true || Boolean( - recognizeAgentProcessFromCommandLine(opts.launchCommand, { includeHeadlessOneShot: true }) + recognizeAgentLaunchCommand(opts.launchCommand, { + includeHeadlessOneShot: true + }) ) if (!explicitlyGuarded && !shouldGuard) { return false