From ca1c4046ed4456f80d19c4b842819f421e955500 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:19:42 -0700 Subject: [PATCH] fix(launch-prompt): plan a host that cannot write its staging folder for main's delivery d8cfcf397a4 refused an agent launch the daemon could not stage, which also refused worker briefs (and AI-button prompts) that main pasted. The staged script and the launch file share one folder per host, so "can write it" now folds into takesLaunchFile, derived before planning in main: this machine's temp folder (checked when planned, a refused write remembered for 30 s) or the WSL distro's folder (the probe's failure, and a refused write, are remembered for the same TTL). A host that cannot write it is planned for main's delivery up front: the paste where main pasted, the line within the typed budget where main typed it. Only a write that fails after the plan said yes is refused. --- src/main/daemon/daemon-pty-session-spawn.ts | 8 ++- .../local-launch-artifact-directory.test.ts | 36 ++++++++++++++ .../local-launch-artifact-directory.ts | 40 +++++++++++++++ src/main/providers/local-pty-spawn.ts | 10 ++-- .../wsl-launch-directory-resolution.test.ts | 48 ++++++++++++------ .../wsl-launch-directory-resolution.ts | 41 +++++++++++----- .../orca-runtime-create-agent-session.ts | 3 +- ...resolve-mobile-session-terminal-command.ts | 7 ++- ...runtime-resolve-worktree-removal-target.ts | 3 +- .../runtime/runtime-worktree-agent-startup.ts | 3 +- .../runtime/this-orca-launch-host.test.ts | 49 +++++++++++++++++++ src/main/runtime/this-orca-launch-host.ts | 25 +++++++++- src/shared/launch-host.ts | 14 ++++-- src/shared/launch-prompt-carry.test.ts | 22 +++++++++ src/shared/launch-prompt-carry.ts | 19 +++---- 15 files changed, 272 insertions(+), 56 deletions(-) create mode 100644 src/main/providers/local-launch-artifact-directory.test.ts create mode 100644 src/main/providers/local-launch-artifact-directory.ts diff --git a/src/main/daemon/daemon-pty-session-spawn.ts b/src/main/daemon/daemon-pty-session-spawn.ts index 04044013701..7f3ba950305 100644 --- a/src/main/daemon/daemon-pty-session-spawn.ts +++ b/src/main/daemon/daemon-pty-session-spawn.ts @@ -18,10 +18,8 @@ import { getRecoveredHistorySeedSegments } from './terminal-history-seed-segment import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, type CreateOrAttachResult } from './types' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { resolveWslSessionContext } from './wsl-session-context' -import { - forgetWslLaunchDirectoryAfterRefusal, - resolveSpawnWslLaunchDirectory -} from '../providers/wsl-launch-directory-resolution' +import { noteLaunchArtifactRefusal } from '../providers/local-launch-artifact-directory' +import { resolveSpawnWslLaunchDirectory } from '../providers/wsl-launch-directory-resolution' import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' import { resolveUnixShellPath } from '../providers/local-pty-utils' import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' @@ -265,7 +263,7 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult { try { result = await this.createOrAttachSpawn(context, context.historySeedSegments) } catch (error) { - forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error) + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) throw error } if (result.isNew && !attachOnly) { diff --git a/src/main/providers/local-launch-artifact-directory.test.ts b/src/main/providers/local-launch-artifact-directory.test.ts new file mode 100644 index 00000000000..b65465fa1b4 --- /dev/null +++ b/src/main/providers/local-launch-artifact-directory.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + localLaunchArtifactsWritable, + noteLaunchArtifactRefusal +} from './local-launch-artifact-directory' +import { WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS } from './wsl-launch-directory-resolution' + +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: vi.fn() })) + +const REFUSAL = new Error( + 'Orca could not write it (ENOSPC), so the agent was not started. [launch_file_unavailable]' +) + +// Why: a host that cannot write its temp folder neither stages a line nor writes a launch file, so +// the next launch is planned without either rather than refused again. +describe('whether this machine can write its staging folder', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('leaves this machine’s folder alone when the refused write was into a WSL distro', () => { + noteLaunchArtifactRefusal({ wslDistro: 'Ubuntu', wslLaunchDirectory: undefined }, REFUSAL) + expect(localLaunchArtifactsWritable()).toBe(true) + }) + + it('is yes until a write there is refused, then again after the failure TTL', () => { + vi.useFakeTimers() + expect(localLaunchArtifactsWritable()).toBe(true) + noteLaunchArtifactRefusal({ wslDistro: null, wslLaunchDirectory: undefined }, new Error('EIO')) + expect(localLaunchArtifactsWritable()).toBe(true) + noteLaunchArtifactRefusal({ wslDistro: null, wslLaunchDirectory: undefined }, REFUSAL) + expect(localLaunchArtifactsWritable()).toBe(false) + vi.advanceTimersByTime(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) + expect(localLaunchArtifactsWritable()).toBe(true) + }) +}) diff --git a/src/main/providers/local-launch-artifact-directory.ts b/src/main/providers/local-launch-artifact-directory.ts new file mode 100644 index 00000000000..e4d54a04fab --- /dev/null +++ b/src/main/providers/local-launch-artifact-directory.ts @@ -0,0 +1,40 @@ +import { accessSync, constants } from 'node:fs' +import { tmpdir } from 'node:os' +import { isLaunchFileRefusal } from '../../shared/launch-prompt-file' +import type { WslLaunchDirectory } from '../../shared/wsl-launch-directory' +import { + noteWslLaunchDirectoryRefusal, + WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS +} from './wsl-launch-directory-resolution' + +let refusedAt: number | null = null + +/** Whether this machine can write its temp folder, where staged lines and launch files go. A write + * refused there counts for the failure TTL, then the folder is checked again. */ +export function localLaunchArtifactsWritable(now = Date.now()): boolean { + if (refusedAt !== null && now - refusedAt < WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) { + return false + } + try { + accessSync(tmpdir(), constants.W_OK) + return true + } catch { + return false + } +} + +/** A spawn refused for its staged line or launch file: the folder it wrote to counts as broken, + * so the next launch is planned without writing there. */ +export function noteLaunchArtifactRefusal( + spawn: { + wslDistro: string | null | undefined + wslLaunchDirectory: WslLaunchDirectory | undefined + }, + error: unknown +): void { + if (spawn.wslDistro) { + noteWslLaunchDirectoryRefusal(spawn.wslLaunchDirectory, error) + } else if (isLaunchFileRefusal(error)) { + refusedAt = Date.now() + } +} diff --git a/src/main/providers/local-pty-spawn.ts b/src/main/providers/local-pty-spawn.ts index 909e4585a2c..180e08de8d3 100644 --- a/src/main/providers/local-pty-spawn.ts +++ b/src/main/providers/local-pty-spawn.ts @@ -30,10 +30,8 @@ import { writeSpawnLaunchFile, type WrittenLaunchFile } from '../../shared/launch-file-writing' -import { - forgetWslLaunchDirectoryAfterRefusal, - resolveSpawnWslLaunchDirectory -} from './wsl-launch-directory-resolution' +import { resolveSpawnWslLaunchDirectory } from './wsl-launch-directory-resolution' +import { noteLaunchArtifactRefusal } from './local-launch-artifact-directory' export async function spawnLocalPty( args: PtySpawnOptions, @@ -69,7 +67,7 @@ export async function spawnLocalPty( wslDirectory: wslLaunchDirectory }) } catch (error) { - forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error) + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) throw error } args = { @@ -84,7 +82,7 @@ export async function spawnLocalPty( return await spawnFreshLocalPty(args, getOptions, reattachId, launchFile) } catch (error) { removeLaunchFile(launchFile) - forgetWslLaunchDirectoryAfterRefusal(wslLaunchDirectory, error) + noteLaunchArtifactRefusal({ wslDistro, wslLaunchDirectory }, error) throw error } } diff --git a/src/main/providers/wsl-launch-directory-resolution.test.ts b/src/main/providers/wsl-launch-directory-resolution.test.ts index 946e6a558e2..0c68c110dfc 100644 --- a/src/main/providers/wsl-launch-directory-resolution.test.ts +++ b/src/main/providers/wsl-launch-directory-resolution.test.ts @@ -3,7 +3,8 @@ import { createLocalPtyLaunchPlan, resolveLocalPtyWslDistro } from './local-pty- import type { LocalPtyProviderOptions } from './local-pty-provider-types' import type { PtySpawnOptions } from './types' import { - forgetWslLaunchDirectoryAfterRefusal, + noteWslLaunchDirectoryRefusal, + wslLaunchDirectoryKnownBroken, resolveSpawnWslLaunchDirectory, resolveWslLaunchDirectory, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS @@ -82,21 +83,38 @@ describe('resolveWslLaunchDirectory', () => { } }) - // Why (stack QA P8-2): a folder found once and broken later must not be written into forever. - it('asks the distro again after a write into the found folder was refused', async () => { - runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) - const found = await resolveWslLaunchDirectory('Void') - forgetWslLaunchDirectoryAfterRefusal(found, new Error('EACCES')) - await resolveWslLaunchDirectory('Void') - expect(runWslProcess).toHaveBeenCalledTimes(1) - forgetWslLaunchDirectoryAfterRefusal( - found, - new Error( - 'Orca could not write it (EEXIST), so the agent was not started. [launch_file_unavailable]' + // Why (stack QA P8-2): a folder found once and broken later must not be written into forever, + // and the next launch is planned without it until the distro is asked again. + it('counts a folder broken after a refused write, then asks the distro again', async () => { + vi.useFakeTimers() + try { + runWslProcess.mockResolvedValue(answers('/home/ada\n/bin/bash\n')) + const found = await resolveWslLaunchDirectory('Void') + noteWslLaunchDirectoryRefusal(found, new Error('EACCES')) + expect(wslLaunchDirectoryKnownBroken('Void')).toBe(false) + noteWslLaunchDirectoryRefusal( + found, + new Error( + 'Orca could not write it (EEXIST), so the agent was not started. [launch_file_unavailable]' + ) ) - ) - await resolveWslLaunchDirectory('Void') - expect(runWslProcess).toHaveBeenCalledTimes(2) + expect(wslLaunchDirectoryKnownBroken('Void')).toBe(true) + await expect(resolveWslLaunchDirectory('Void')).resolves.toBeUndefined() + expect(runWslProcess).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS) + expect(wslLaunchDirectoryKnownBroken('Void')).toBe(false) + await expect(resolveWslLaunchDirectory('Void')).resolves.toMatchObject({ distro: 'Void' }) + expect(runWslProcess).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('counts a distro whose probe failed broken, for planning', async () => { + runWslProcess.mockResolvedValue(answers('', 1)) + await resolveWslLaunchDirectory('Mint') + await Promise.resolve() + expect(wslLaunchDirectoryKnownBroken('Mint')).toBe(true) }) it('probes only for a spawn that needs the directory', async () => { diff --git a/src/main/providers/wsl-launch-directory-resolution.ts b/src/main/providers/wsl-launch-directory-resolution.ts index a7bab41972b..e44dfc7baa8 100644 --- a/src/main/providers/wsl-launch-directory-resolution.ts +++ b/src/main/providers/wsl-launch-directory-resolution.ts @@ -11,6 +11,8 @@ import { runWslProcess } from '../wsl/wsl-runner' const ORCA_CACHE_RELATIVE = '.cache/orca' const probes = new Map>() +/** Distros whose folder a probe or a write found broken, until the failure TTL lets them retry. */ +const failedDistros = new Set() /** A failed probe is answered from memory this long, so a broken distro does not cost every spawn * a `wsl.exe` round trip, yet a distro that comes back is found again. */ @@ -41,8 +43,8 @@ export function resolveSpawnWslLaunchDirectory( * The distro directory a WSL spawn's staged line and launch file are written to, and the login * shell the pane runs, which decides how a staged line is sourced; undefined when the distro cannot * be asked, and the write site then refuses a launch that needs one. Probed once per distro; a - * success is kept until a write into it fails (`forgetWslLaunchDirectoryAfterRefusal`), a failure - * for `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. + * success is kept until a write into it fails (`noteWslLaunchDirectoryRefusal`), a failure for + * `WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS`. */ export async function resolveWslLaunchDirectory( distro: string | null | undefined @@ -56,26 +58,41 @@ export async function resolveWslLaunchDirectory( probes.set(distro, probe) const pending = probe void pending.then((found) => { - if (!found) { - setTimeout(() => { - if (probes.get(distro) === pending) { - probes.delete(distro) - } - }, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.() + if (found) { + failedDistros.delete(distro) + } else { + rememberFailure(distro, pending) } }) } return await probe } -/** A spawn into this directory was refused: forget it, so the next launch asks the distro again - * rather than writing into a folder that stopped working. */ -export function forgetWslLaunchDirectoryAfterRefusal( +function rememberFailure(distro: string, pending: Promise): void { + failedDistros.add(distro) + setTimeout(() => { + if (probes.get(distro) === pending) { + probes.delete(distro) + failedDistros.delete(distro) + } + }, WSL_LAUNCH_DIRECTORY_FAILURE_TTL_MS).unref?.() +} + +/** Whether the distro's folder is known broken, so a launch is planned without writing there. */ +export function wslLaunchDirectoryKnownBroken(distro: string): boolean { + return failedDistros.has(distro) +} + +/** A spawn into this directory was refused: count it broken until the failure TTL, so the next + * launch is planned without it and a later one asks the distro again. */ +export function noteWslLaunchDirectoryRefusal( directory: WslLaunchDirectory | undefined, error: unknown ): void { if (directory && isLaunchFileRefusal(error)) { - probes.delete(directory.distro) + const pending = Promise.resolve(undefined) + probes.set(directory.distro, pending) + rememberFailure(directory.distro, pending) } } diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 403d649516c..d4358df9aad 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -187,7 +187,8 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe host: thisOrcaLaunchHost({ launchPlatform: startupArgs.platform, isRemote: Boolean(workspace.connectionId), - settings + settings, + workspacePath: workspace.path }), // Why: this create returns before the agent is ready, so nothing pastes after it. paste: 'never' diff --git a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts index 358f9991b48..ac135bb79aa 100644 --- a/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts +++ b/src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts @@ -61,7 +61,12 @@ export class OrcaRuntimeWithResolveMobileSessionTerminalCommand extends OrcaRunt isRemote }), prompt: opts.agentPrompt ?? '', - host: thisOrcaLaunchHost({ launchPlatform, isRemote, settings }), + host: thisOrcaLaunchHost({ + launchPlatform, + isRemote, + settings, + workspacePath: workspace.path + }), // Why: a quick command has no paste after ready. paste: 'never' }) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 2ddc02021d0..104c1ff012f 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -275,7 +275,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith launchPlatform: platform, isRemote, settings, - windowsShellOverride: opts.shellOverride + windowsShellOverride: opts.shellOverride, + workspacePath: workspace.path }), paste: opts.onStartupPromptCarry ? 'when-host-proves-agent' : 'never' }) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index d09734a518f..7a7e255c1c5 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -161,7 +161,8 @@ export function buildWorktreeStartupForAgent( host: thisOrcaLaunchHost({ isRemote: repoIsRemote(repo), launchPlatform: environment.getLaunchPlatform(), - settings + settings, + workspacePath: repo.path }), // The caller, or else the host's follow-up, pastes what the line leaves once the agent runs. paste: 'when-host-proves-agent' diff --git a/src/main/runtime/this-orca-launch-host.test.ts b/src/main/runtime/this-orca-launch-host.test.ts index 73965d0cc6a..fe34375b618 100644 --- a/src/main/runtime/this-orca-launch-host.test.ts +++ b/src/main/runtime/this-orca-launch-host.test.ts @@ -1,8 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cachedPwshAvailability } from '../pwsh' +import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory' +import { wslLaunchDirectoryKnownBroken } from '../providers/wsl-launch-directory-resolution' import { thisOrcaLaunchHost } from './this-orca-launch-host' vi.mock('../pwsh', () => ({ cachedPwshAvailability: vi.fn(() => null) })) +vi.mock('../providers/local-launch-artifact-directory', () => ({ + localLaunchArtifactsWritable: vi.fn(() => true) +})) +vi.mock('../providers/wsl-launch-directory-resolution', () => ({ + wslLaunchDirectoryKnownBroken: vi.fn(() => false) +})) const realPlatform = process.platform @@ -51,3 +59,44 @@ describe('the PowerShell a launch this Orca runs lands in', () => { ).toBeNull() }) }) + +// Why: a host that cannot write its staging folder can neither stage a line nor write a launch +// file there, so the launch is planned for main's delivery up front instead of refused. +describe('whether a launch this Orca runs can write its staging folder', () => { + afterEach(() => { + onPlatform(realPlatform) + vi.mocked(localLaunchArtifactsWritable).mockReturnValue(true) + vi.mocked(wslLaunchDirectoryKnownBroken).mockReturnValue(false) + }) + + it('follows this machine’s temp folder for a local launch', () => { + const host = () => + thisOrcaLaunchHost({ launchPlatform: 'darwin', isRemote: false, settings: null }) + onPlatform('darwin') + expect(host().takesLaunchFile).toBe(true) + vi.mocked(localLaunchArtifactsWritable).mockReturnValue(false) + expect(host().takesLaunchFile).toBe(false) + }) + + it('follows the distro’s folder for a WSL workspace', () => { + onPlatform('win32') + vi.mocked(wslLaunchDirectoryKnownBroken).mockImplementation((distro) => distro === 'qasfwin') + const host = (workspacePath: string) => + thisOrcaLaunchHost({ + launchPlatform: 'linux', + isRemote: false, + settings: null, + workspacePath + }) + expect(host('\\\\wsl.localhost\\qasfwin\\root\\fixture').takesLaunchFile).toBe(false) + expect(host('\\\\wsl.localhost\\Ubuntu\\home\\ada').takesLaunchFile).toBe(true) + }) + + it('cannot check an SSH host’s folder from here', () => { + vi.mocked(localLaunchArtifactsWritable).mockReturnValue(false) + expect( + thisOrcaLaunchHost({ launchPlatform: 'linux', isRemote: true, settings: null }) + .takesLaunchFile + ).toBe(true) + }) +}) diff --git a/src/main/runtime/this-orca-launch-host.ts b/src/main/runtime/this-orca-launch-host.ts index d3aa8813f89..c4f794c4b57 100644 --- a/src/main/runtime/this-orca-launch-host.ts +++ b/src/main/runtime/this-orca-launch-host.ts @@ -1,4 +1,7 @@ import { cachedPwshAvailability } from '../pwsh' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { localLaunchArtifactsWritable } from '../providers/local-launch-artifact-directory' +import { wslLaunchDirectoryKnownBroken } from '../providers/wsl-launch-directory-resolution' import { describeLaunchHost, spawnedWindowsShell, @@ -13,6 +16,8 @@ export function thisOrcaLaunchHost(args: { settings: WindowsShellSettings /** The shell this launch asked for (`--shell`), which outranks the setting. */ windowsShellOverride?: string | null + /** Where the pane opens; a WSL path names the distro its line and file are written into. */ + workspacePath?: string }): LaunchHost { const local = !args.isRemote && args.launchPlatform === 'win32' && process.platform === 'win32' return describeLaunchHost({ @@ -26,6 +31,24 @@ export function thisOrcaLaunchHost(args: { windowsShellOverride: args.windowsShellOverride, pwshAvailable: cachedPwshAvailability() }) - : null + : null, + writesLaunchArtifacts: writesLaunchArtifacts(args) }) } + +/** Whether this host can write the folder a staged line and a launch file go in. An SSH host's is + * the relay's, which this client cannot check. */ +function writesLaunchArtifacts(args: { + launchPlatform: NodeJS.Platform + isRemote: boolean + workspacePath?: string +}): boolean { + if (args.isRemote) { + return true + } + if (process.platform === 'win32' && args.launchPlatform !== 'win32') { + const distro = args.workspacePath ? parseWslUncPath(args.workspacePath)?.distro : undefined + return !distro || !wslLaunchDirectoryKnownBroken(distro) + } + return localLaunchArtifactsWritable() +} diff --git a/src/shared/launch-host.ts b/src/shared/launch-host.ts index 3eb96f71b61..29741287e5c 100644 --- a/src/shared/launch-host.ts +++ b/src/shared/launch-host.ts @@ -21,9 +21,10 @@ export type LaunchHost = { /** Whether the host can prove the launched agent holds its terminal before a paste * (`launched-agent-foreground`). A Windows host cannot, so #24257's guarded paste is refused. */ provesAgentInFront: boolean - /** Whether the host writes a launch file the agent can read. A paired Orca may be older, and an - * SSH Windows host's relay may run its panes in WSL (its OpenSSH default shell, which this client - * cannot see), where it writes none. Such a host gets the line or the paste instead. */ + /** Whether the host writes a launch file the agent can read, and stages a long line, both in one + * folder. A paired Orca may be older, an SSH Windows host's relay may run its panes in WSL (its + * OpenSSH default shell, which this client cannot see), and a host may find that folder + * unwritable. Such a host gets the line within its typed budget, or the paste, instead. */ takesLaunchFile: boolean /** The shell a local Windows pane is spawned as, which the line is judged and quoted by: a WSL * pane runs the distro's POSIX shell whatever the Windows setting, and the PowerShell decides @@ -80,12 +81,17 @@ export function describeLaunchHost(args: { paired: boolean /** `spawnedWindowsShell` on this machine, which a launch elsewhere does not use. */ windowsPaneShell?: WindowsPaneShell | null + /** Whether the host can write the folder its staged lines and launch files go in. */ + writesLaunchArtifacts?: boolean }): LaunchHost { const runsElsewhere = args.isRemote || args.paired return { paired: args.paired, provesAgentInFront: (runsElsewhere ? args.launchPlatform : args.hostPlatform) !== 'win32', - takesLaunchFile: !args.paired && !(args.isRemote && args.launchPlatform === 'win32'), + takesLaunchFile: + !args.paired && + !(args.isRemote && args.launchPlatform === 'win32') && + args.writesLaunchArtifacts !== false, windowsPaneShell: runsElsewhere || args.launchPlatform !== 'win32' ? null : (args.windowsPaneShell ?? null) } diff --git a/src/shared/launch-prompt-carry.test.ts b/src/shared/launch-prompt-carry.test.ts index 7619ff4bc53..2b3328116d8 100644 --- a/src/shared/launch-prompt-carry.test.ts +++ b/src/shared/launch-prompt-carry.test.ts @@ -218,6 +218,28 @@ describe('a host that types the line raw', () => { expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') }) + // Why: a host that cannot write its staging folder types a long line raw, so the plan picks + // main's delivery up front: the paste where main pasted, the line where main typed it. + it('plans a host that cannot stage for main’s delivery, not a staged line or a file', () => { + const extra = { + platform: 'linux' as const, + host: { + paired: false, + provesAgentInFront: true, + takesLaunchFile: false, + windowsPaneShell: null + } + } + const long = 'y'.repeat(20_000) + expect(plan('claude', 'fix it', extra)?.carry).toBe('on-line') + expect(plan('claude', long, { ...extra, paste: 'once-agent-runs' })?.carry).toBe( + 'paste-after-ready' + ) + expect(plan('claude', long, extra)?.carry).toBe('paste-after-ready') + expect(plan('codex', 'first line\nsecond line', extra)?.carry).toBe('paste-after-ready') + expect(plan('claude', long, { ...extra, paste: 'never' })?.carry).toBe('on-line') + }) + it('pastes on a paired host what its line cannot carry typed, even for Claude', () => { const extra = { platform: 'linux' as const, diff --git a/src/shared/launch-prompt-carry.ts b/src/shared/launch-prompt-carry.ts index b5162e059e0..28502beb028 100644 --- a/src/shared/launch-prompt-carry.ts +++ b/src/shared/launch-prompt-carry.ts @@ -104,10 +104,11 @@ const utf8 = new TextEncoder() * stages it when it is long or multi-line, and a Windows host types it when its shell was measured * carrying such a line exactly (`windowsLaunchLineVerdict`); an unmeasured one does what main does * on the caller's path. When the line cannot (past the argv ceiling, measured damaged by a Windows - * shell, past a paired host's typed budget), a caller whose paste main used gets that paste, so the - * agent receives the user's text. Otherwise it rides a launch file, which goes only to an agent measured reading - * one, on a host that writes it. Failing a file it is pasted once the agent is ready, unless the - * caller's paste cannot reach the agent on this host: then the line carries it, as main typed it. + * shell, past the typed budget of a paired host or one that cannot write its staging folder), a + * caller whose paste main used gets that paste, so the agent receives the user's text. Otherwise it + * rides a launch file, which goes only to an agent measured reading one, on a host that writes it. + * Failing a file it is pasted once the agent is ready, unless the caller's paste cannot reach the + * agent on this host: then the line carries it, as main typed it. */ export function carryLaunchPrompt( args: A, @@ -180,11 +181,11 @@ export function carryLaunchPrompt