Merge remote-tracking branch 'origin/main' into nwparker/UX-delete-worktree-jump-to-top-no-good

This commit is contained in:
Neil
2026-08-23 12:47:19 -07:00
83 changed files with 3306 additions and 324 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 26m">
<title>downloads: 26m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 27m">
<title>downloads: 27m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">26m</text>
<text x="90" y="14">26m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">27m</text>
<text x="90" y="14">27m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -121,6 +121,25 @@ describe('buildMobileAiVaultResumeCommand', () => {
})
describe('buildMobileAiVaultResumeLaunch', () => {
it('routes Kimi through the resumable-agent startup plan', () => {
// Why: kimi joining RESUMABLE_TUI_AGENTS moves it off the plain command fallback, so the
// cd prefix Kimi needs (sessions are work-dir-scoped) must survive the new branch.
const launch = buildMobileAiVaultResumeLaunch({
session: session({
agent: 'kimi',
sessionId: 'session_431324d7-2165-42f0-9ecd-9f93437b3201'
}),
hostPlatform: 'darwin'
})
expect(launch).toMatchObject({
command:
"cd '/Users/ada/repo' && kimi '--yolo' '--session' 'session_431324d7-2165-42f0-9ecd-9f93437b3201'",
launchConfig: { agentCommand: "kimi '--yolo'" },
launchAgent: 'kimi'
})
})
it('preserves an arbitrary OMP transcript locator for later cold resume', () => {
const launch = buildMobileAiVaultResumeLaunch({
session: session({
+7 -1
View File
@@ -77,7 +77,13 @@ export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): s
}
function buildCodexPathLookup(): string {
return buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' })
// Same skip as agent detection: otherwise detection can report the guest
// codex while this resolves the Windows one ahead of it on PATH, and Orca
// launches a different binary than the one it said was installed.
return buildPosixCommandPathLookupScript(
{ kind: 'literal', value: 'codex' },
{ skipWindowsMountDirs: true }
)
}
function buildWslCodexShellArgs(distro: string, command: string): string[] {
+42 -1
View File
@@ -1,5 +1,5 @@
import type * as FsPromises from 'node:fs/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -67,6 +67,10 @@ beforeEach(() => {
})
describe('addSparseWorktree', () => {
// Why: argv now depends on the host OS, so pin a non-Windows default or the exact-argv
// assertions below would fail for a maintainer running vitest on Windows.
let platformSpy: MockInstance<() => NodeJS.Platform>
beforeEach(() => {
resetWorktreeGitMocks({
gitExecFileAsyncMock,
@@ -75,6 +79,43 @@ describe('addSparseWorktree', () => {
statMock,
resolveGitDirMock
})
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
afterEach(() => {
platformSpy.mockRestore()
})
it('enables long paths on the Windows commands that actually write the deep checkout', async () => {
// Why: `worktree add --no-checkout` writes nothing, so the long-path flag on it alone
// left sparse creation failing with "Filename too long" (issue #15785).
platformSpy.mockReturnValue('win32')
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await addSparseWorktree('C:\\repo', 'C:\\repo-feature', 'feature/test', ['packages/web'])
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git -c core.longpaths=true sparse-checkout set -- packages/web',
'git -c core.longpaths=true checkout feature/test'
])
)
})
it('omits the long-path option on non-Windows hosts', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
const calls = getGitCalls()
expect(calls).toEqual(
expect.arrayContaining([
'git sparse-checkout set -- packages/web',
'git checkout feature/test'
])
)
expect(calls.some((call) => call.includes('core.longpaths'))).toBe(false)
})
it('separates sparse checkout directory operands from options', async () => {
@@ -1,5 +1,5 @@
// addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -40,10 +40,19 @@ describe('addWorktree', () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
}
// Why: argv now depends on the host OS, so pin a non-Windows default or every
// exact-argv assertion below would fail for a maintainer running vitest on Windows.
let platformSpy: MockInstance<() => NodeJS.Platform>
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
afterEach(() => {
platformSpy.mockRestore()
})
it('creates the worktree without touching the local base ref by default', async () => {
@@ -99,6 +108,86 @@ describe('addWorktree', () => {
])
})
it('enables long paths for native Windows worktree creation', async () => {
platformSpy.mockReturnValue('win32')
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
await addWorktree(
'C:\\repo',
'C:\\repo-feature',
'feature/test',
'feature/test',
false,
false,
{ checkoutExistingBranch: true }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
{ cwd: 'C:\\repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
)
})
it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => {
// Why: a C:\ cwd can be served by host git.exe even with wslDistro set, and that
// is exactly the MAX_PATH-prone case; Linux git parses and ignores the key.
platformSpy.mockReturnValue('win32')
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
await addWorktree(
'C:\\repo',
'C:\\repo-feature',
'feature/test',
'feature/test',
false,
false,
{ checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
{ cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
)
})
it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => {
platformSpy.mockReturnValue('win32')
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo'
await addWorktree(
repoPath,
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
'feature/test',
'feature/test',
false,
false,
{ checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['worktree', 'add', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', 'feature/test'],
{ cwd: repoPath, wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
)
})
it.each(['darwin', 'linux'] as const)(
'does not pass the long-path option on %s',
async (platform) => {
platformSpy.mockReturnValue(platform)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
checkoutExistingBranch: true
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['worktree', 'add', '/repo-feature', 'feature/test'],
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
)
}
)
it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
// Why: without a timeout, a OneDrive cloud-placeholder checkout can stall
// `git worktree add` for minutes. Assert the runner receives a non-zero
@@ -347,13 +347,19 @@ describe('addWorktree', () => {
it('skips updating the local branch when it has diverged', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
resolveCreationBaseConfigWrite()
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_not_fast_forward',
baseRef: 'origin/main',
localBranch: 'main'
})
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
@@ -363,6 +369,10 @@ describe('addWorktree', () => {
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
[
'worktree',
@@ -396,12 +406,112 @@ describe('addWorktree', () => {
])
})
// #15331: evaluation runs before `-b <branch>` exists, so rev-list fails on the missing local ref.
it('does not warn when worktree add itself creates the local base branch', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/feature-x^{commit}
.mockRejectedValueOnce(
new Error(
"fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree."
)
) // rev-list: refs/heads/feature-x does not exist yet
.mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/feature-x (missing)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree(
'/repo',
'/repo-feature-x',
'feature-x',
'origin/feature-x',
true
)
expect(result.localBaseRefRefresh).toBeUndefined()
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
'worktree',
'add',
'--no-track',
'-b',
'feature-x',
'/repo-feature-x',
'refs/remotes/origin/feature-x'
])
// Nothing was refreshed, so no ref mutation.
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref')
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset')
})
// #15331: same missing-local-branch class, but the new branch name differs from the base's.
it('does not warn when the local base branch does not exist in a fetch-only clone', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit}
.mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-list: no local main
.mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/main (missing)
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true)
expect(result.localBaseRefRefresh).toBeUndefined()
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
'for-each-ref',
'--count=1',
'--format=%(refname)',
'refs/heads/main'
])
})
// A failed probe is not proof of absence, so the warning must survive it.
it('keeps the warning when the local base ref probe itself fails', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit}
.mockRejectedValueOnce(new Error('rev-list failed')) // drift probe
.mockRejectedValueOnce(new Error('fatal: not a git repository')) // for-each-ref probe could not run
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true)
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_not_fast_forward',
baseRef: 'origin/main',
localBranch: 'main'
})
})
it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit}
.mockResolvedValueOnce({ stdout: '2\t3\n' }) // rev-list: 2 local-only commits
.mockResolvedValueOnce({ stdout: '' }) // worktree add
.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
const result = await addWorktree('/repo', '/repo-feature', 'main', 'origin/main', true)
// Same branch name as the base, but rev-list succeeded: real divergence must still warn.
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_not_fast_forward',
baseRef: 'origin/main',
localBranch: 'main'
})
})
it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
resolveCreationBaseConfigWrite()
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
@@ -435,6 +545,10 @@ describe('addWorktree', () => {
['merge-base', '--is-ancestor', 'new-local', 'remote-main'],
expect.objectContaining({ cwd: '/repo' })
],
[
['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'],
expect.objectContaining({ cwd: '/repo' })
],
[
[
'worktree',
+29
View File
@@ -37,3 +37,32 @@ export async function hasWorktreeBaseCommitRef(
): Promise<boolean> {
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
}
export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown'
/**
* Distinguish "the ref does not exist" from "the probe itself failed".
*
* Why for-each-ref: it exits 0 whether or not the pattern matches, so an empty result
* proves absence while a rejection still means the probe never ran (broken repo, dead
* SSH transport). `rev-parse --verify --quiet` exits 1 for both, and reading that as
* "absent" would silently drop warnings the caller must still surface.
*
* Executor-injected so the SSH path can route the same argv through the relay.
*/
export async function probeWorktreeBaseRefPresence(
runGit: (args: string[]) => Promise<{ stdout: string }>,
qualifiedRef: string
): Promise<WorktreeBaseRefPresence> {
try {
const { stdout } = await runGit([
'for-each-ref',
'--count=1',
'--format=%(refname)',
qualifiedRef
])
return stdout.trim() === qualifiedRef ? 'present' : 'absent'
} catch {
return 'unknown'
}
}
+22 -4
View File
@@ -14,6 +14,7 @@ import {
scheduleWorktreeTrashDeletion
} from '../worktree-trash'
import { parseWslPath } from '../wsl'
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
@@ -33,7 +34,7 @@ import {
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir, runWithGitReadCacheInvalidation } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { hasWorktreeBaseCommitRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
export type AddWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
@@ -265,6 +266,16 @@ async function evaluateLocalBaseRefRefreshability(
)
drift = parsedDrift
} catch {
// Why (#15331): the probes above also fail when refs/heads/<branch> is simply absent; a branch that
// does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning.
// Only a proven absence suppresses: an unusable repo leaves the warning alone.
const presence = await probeWorktreeBaseRefPresence(
(args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)),
parsed.fullRef
)
if (presence === 'absent') {
return undefined
}
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
@@ -975,7 +986,8 @@ async function performAddWorktree(
): Promise<AddWorktreeResult> {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
const args = ['worktree', 'add']
// Why: enable long paths for this Windows checkout without changing user Git config.
const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add']
let effectiveBase: string | undefined
if (noCheckout) {
args.push('--no-checkout')
@@ -1081,15 +1093,21 @@ export async function addSparseWorktree(
options
)
created = true
// Why: `worktree add --no-checkout` writes no files, so these are the calls that
// actually materialize the deep path and need the long-path escape hatch.
const longPathArgs = windowsLongPathGitArgs(worktreePath)
await gitExecFileAsync(
['sparse-checkout', 'init', '--cone'],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(
['sparse-checkout', 'set', '--', ...directories],
[...longPathArgs, 'sparse-checkout', 'set', '--', ...directories],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(
[...longPathArgs, 'checkout', branch],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options))
return addResult
} catch (error) {
const wrapped: SparseWorktreeCreateError =
@@ -63,6 +63,43 @@ describe('ElectronSecretStore', () => {
expect(safeStorageMock.getSelectedStorageBackend).not.toHaveBeenCalled()
})
it('does not throw when the Linux-only backend probe is absent', () => {
// Why this test exists: getSelectedStorageBackend is @platform linux, so it is
// genuinely undefined on macOS and Windows — verified against Electron 43. Every
// other test here mocks safeStorage with the method present, so without this the
// suite could stay green while the shipped app threw at startup.
//
// Why delete the key rather than re-mock: the module already holds this object, so
// a later vi.doMock is inert and the test would pass against unguarded code.
const probe = safeStorageMock.getSelectedStorageBackend
// @ts-expect-error deleting a required member is the condition under test
delete safeStorageMock.getSelectedStorageBackend
try {
withPlatform('linux', () => {
expect(() => new ElectronSecretStore().describeProtectionGap()).not.toThrow()
expect(new ElectronSecretStore().describeProtectionGap()).toBeNull()
})
} finally {
safeStorageMock.getSelectedStorageBackend = probe
}
})
it('reports no gap when the backend probe throws', () => {
safeStorageMock.getSelectedStorageBackend.mockImplementation(() => {
throw new Error('backend unavailable')
})
withPlatform('linux', () => {
expect(new ElectronSecretStore().describeProtectionGap()).toBeNull()
})
})
it('reports no gap for an unknown backend rather than guessing', () => {
safeStorageMock.getSelectedStorageBackend.mockReturnValue('unknown')
withPlatform('linux', () => {
expect(new ElectronSecretStore().describeProtectionGap()).toBeNull()
})
})
it('reports the missing-keyring gap before considering the backend', () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
withPlatform('linux', () => {
+33 -4
View File
@@ -31,9 +31,38 @@ export class ElectronSecretStore implements SecretStore {
// It round-trips, so sealing and unsealing genuinely work and must keep working —
// reporting it unavailable would strand every credential already stored this way.
// But it protects nothing, and reporting it as sealed is the actual lie.
if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend() === 'basic_text') {
return 'Secrets are obfuscated with a built-in key, not protected by the OS keyring. Install and unlock gnome-keyring or kwallet, then restart Orca, to seal them properly.'
}
return null
return describeLinuxBackendGap()
}
}
/**
* Why probed rather than called directly: `getSelectedStorageBackend` is `@platform
* linux`, so it is genuinely absent on macOS and Windows — reading it unguarded is a
* TypeError, and the type declaration does not say so. Tests that mock `safeStorage`
* supply the method, so a mocked suite cannot catch that; this keeps the runtime honest
* regardless of platform or Electron version.
*
* Returns null when the backend is unknown or unreadable: claiming a gap we cannot
* prove would be its own kind of lie, and the caller has already established that
* sealing works.
*/
function describeLinuxBackendGap(): string | null {
if (process.platform !== 'linux') {
return null
}
const probe = (safeStorage as Partial<typeof safeStorage>).getSelectedStorageBackend
if (typeof probe !== 'function') {
return null
}
let backend: string
try {
backend = probe.call(safeStorage)
} catch {
return null
}
// Why only basic_text: it "encrypts" with a hardcoded password, so it round-trips and
// must keep working — but it protects nothing, and reporting it as sealed is the lie.
return backend === 'basic_text'
? 'Secrets are obfuscated with a built-in key, not protected by the OS keyring. Install and unlock gnome-keyring or kwallet, then restart Orca, to seal them properly.'
: null
}
+82 -23
View File
@@ -1,38 +1,97 @@
import { describe, expect, it } from 'vitest'
import { setSecretStore, _resetSecretStoreForTests } from '../../shared/secret-store'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store'
import { reportSecretProtectionGap } from './secret-protection-report'
function installStore(gap: string | null): void {
setSecretStore({
isEncryptionAvailable: () => true,
encryptString: (plainText) => Buffer.from(plainText),
decryptString: (cipher) => cipher.toString(),
describeProtectionGap: () => gap
})
}
const WEAK = 'Secrets are obfuscated with a built-in key.'
describe('reportSecretProtectionGap', () => {
it('logs and returns the gap when protection is weaker than a user would assume', () => {
installStore('Secrets are obfuscated with a built-in key.')
const logged: string[] = []
expect(reportSecretProtectionGap((m) => logged.push(m))).toMatch(/built-in key/)
expect(logged).toHaveLength(1)
expect(logged[0]).toContain('[secrets]')
let dir: string
let dataFile: string
let logged: string[]
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-secret-report-'))
dataFile = join(dir, 'orca-data.json')
logged = []
})
it('stays silent when secrets are properly sealed', () => {
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
function installStore(gap: string | null): void {
setSecretStore({
isEncryptionAvailable: () => true,
encryptString: (plainText) => Buffer.from(plainText),
decryptString: (cipher) => cipher.toString(),
describeProtectionGap: () => gap
})
}
const report = (force = false): string | null =>
reportSecretProtectionGap({ dataFile, log: (m) => void logged.push(m), force })
it('reports a new gap once, then stays quiet on every later launch', () => {
// Why this is the point: the gap usually needs a keyring install to fix, so
// repeating it every start is nagging the user cannot act on.
installStore(WEAK)
expect(report()).toBe(WEAK)
expect(logged).toHaveLength(1)
report()
report()
expect(logged).toHaveLength(1)
})
it('reports again when the gap changes to a different reason', () => {
installStore(WEAK)
report()
installStore('The OS keyring is unavailable.')
report()
expect(logged).toHaveLength(2)
expect(logged[1]).toContain('keyring is unavailable')
})
it('announces recovery, so an earlier warning is not left standing', () => {
installStore(WEAK)
report()
installStore(null)
const logged: string[] = []
expect(reportSecretProtectionGap((m) => logged.push(m))).toBeNull()
report()
expect(logged).toHaveLength(2)
expect(logged[1]).toMatch(/now provided by the OS keyring/)
report()
expect(logged).toHaveLength(2)
})
it('stays silent from a clean start when secrets are properly sealed', () => {
installStore(null)
expect(report()).toBeNull()
expect(logged).toEqual([])
})
it('re-reports on demand for support, without disturbing stored state', () => {
installStore(WEAK)
report()
expect(report(true)).toBe(WEAK)
expect(logged).toHaveLength(2)
report()
expect(logged).toHaveLength(2)
})
it('re-reports when the stored state is corrupt rather than trusting it', () => {
installStore(WEAK)
writeFileSync(join(dir, 'orca-secret-protection.json'), '{ not json', 'utf-8')
expect(report()).toBe(WEAK)
expect(logged).toHaveLength(1)
})
it('does not throw startup when no store is installed', () => {
// Why: this is diagnostics running early in bootstrap. The useful error is the one
// raised by the first real read, not a crash from the thing reporting on it.
_resetSecretStoreForTests()
const logged: string[] = []
expect(() => reportSecretProtectionGap((m) => logged.push(m))).not.toThrow()
expect(() => report()).not.toThrow()
expect(logged[0]).toContain('could not determine at-rest protection')
})
})
+62 -8
View File
@@ -1,19 +1,53 @@
import { dirname, join } from 'node:path'
import { readFileSync, writeFileSync } from 'node:fs'
import { getSecretStore } from '../../shared/secret-store'
/**
* Report at-rest secret protection once at startup.
* Report at-rest secret protection, but only when the answer changes.
*
* Why this exists: the port has always been able to describe a protection gap, and
* nothing read it — so a Linux user whose secrets are obfuscated with a built-in key
* was told nothing at all. Reading it here is the difference between the port
* documenting a promise and keeping one.
* was told nothing at all.
*
* Why not every launch: the gap is usually not something the user can fix right now
* (it needs a keyring installed and unlocked), so repeating it every start is nagging
* they cannot act on and will learn to ignore. Reporting on change means they hear it
* when it starts being true, hear it again if it becomes true for a different reason,
* and are told once when it is fixed.
*
* Deliberately not fatal and not a dialog: sealing still works, so blocking startup
* would be worse than the gap it reports.
*/
export function reportSecretProtectionGap(
log: (message: string) => void = (message) => console.warn(message)
): string | null {
type ReportOptions = {
/** Profile data file; the report state lives beside it. */
dataFile: string
log?: (message: string) => void
/** Escape hatch for support: re-report even if unchanged. */
force?: boolean
}
type ReportState = { lastReportedGap: string | null }
function stateFile(dataFile: string): string {
return join(dirname(dataFile), 'orca-secret-protection.json')
}
function readState(path: string): ReportState | null {
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
if (parsed && typeof parsed === 'object' && 'lastReportedGap' in parsed) {
const value = (parsed as ReportState).lastReportedGap
return { lastReportedGap: typeof value === 'string' ? value : null }
}
} catch {
// Missing or corrupt: treat as never reported and report again.
}
return null
}
export function reportSecretProtectionGap(options: ReportOptions): string | null {
const log = options.log ?? ((message: string) => console.warn(message))
let gap: string | null
try {
gap = getSecretStore().describeProtectionGap()
@@ -23,8 +57,28 @@ export function reportSecretProtectionGap(
log(`[secrets] could not determine at-rest protection: ${String(error)}`)
return null
}
if (gap) {
log(`[secrets] ${gap}`)
const path = stateFile(options.dataFile)
const previous = readState(path)
const changed = previous === null || previous.lastReportedGap !== gap
if (changed || options.force) {
if (gap) {
log(`[secrets] ${gap}`)
} else if (previous?.lastReportedGap) {
// Why announce the recovery: the previous warning said secrets were not protected,
// so silence would leave the user assuming that is still true.
log('[secrets] At-rest protection is now provided by the OS keyring.')
}
}
if (changed) {
try {
writeFileSync(path, JSON.stringify({ lastReportedGap: gap }), 'utf-8')
} catch (error) {
// Best effort: re-reporting next launch is strictly better than failing startup.
log(`[secrets] could not persist the protection report state: ${String(error)}`)
}
}
return gap
}
+6 -3
View File
@@ -884,9 +884,6 @@ if (hasSingleInstanceLock) {
// the app.setName ordering the userData captures below depend on.
setAppEnvironment(new ElectronAppEnvironment())
setSecretStore(new ElectronSecretStore())
// Why right after install: this is the first moment the answer is knowable, and a
// silent weak backend is the gap users most need told about.
reportSecretProtectionGap()
// Why at process level, not per-window: pty.ts registers against injected surfaces so
// it can load without electron, and an Electron main process always has ipcMain —
// whether a window exists is irrelevant. Installing this in attachMainWindowServices
@@ -2310,6 +2307,12 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
// Why here and not at install time: the report remembers what it last said, and that
// state lives beside the profile data file, which does not exist until now.
reportSecretProtectionGap({
dataFile: activeOrcaProfile.dataFile,
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1'
})
// Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
// it. Left unbound it reports nothing trusted, which is safe but silently discards our own
// accept records on every launch.
+6 -1
View File
@@ -35,7 +35,12 @@ describe('isCommandOnPath', () => {
expect(runPreflightCommandInWslMock).toHaveBeenCalledOnce()
const [, command] = runPreflightCommandInWslMock.mock.calls[0] as [{ distro: string }, string]
expect(command).toContain(
buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' })
buildPosixCommandPathLookupScript(
{ kind: 'literal', value: 'codex' },
// The WSL branch skips Windows mounts, so detection and this check
// cannot disagree about the same distro.
{ skipWindowsMountDirs: true }
)
)
expect(command).toContain(
['if [ -n "$resolved" ]; then', `printf '${sentinel}%s\\n' "$resolved"`, 'fi'].join('\n')
+7 -1
View File
@@ -100,7 +100,13 @@ export async function isCommandOnPath(
const { stdout } = await execCommandInWsl(
wslTarget,
[
buildPosixCommandPathLookupScript({ kind: 'literal', value: command }),
// Same skip as agent detection: without it this branch answers "yes"
// for a Windows binary reached through interop, so preflight and the
// detector disagree about the same distro.
buildPosixCommandPathLookupScript(
{ kind: 'literal', value: command },
{ skipWindowsMountDirs: true }
),
'if [ -n "$resolved" ]; then',
`printf '${WSL_COMMAND_PATH_SENTINEL}%s\\n' "$resolved"`,
'fi'
@@ -1,5 +1,5 @@
import { execFileSync, type ExecFileSyncOptions } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -56,10 +56,11 @@ describe('detectWslCommandsOnPath', () => {
await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex'])
const { script } = lastSpec()
const lookupScript = buildPosixCommandPathLookupScript({
kind: 'shell-variable',
name: 'cmd'
})
const lookupScript = buildPosixCommandPathLookupScript(
{ kind: 'shell-variable', name: 'cmd' },
// The WSL probe opts into skipping Windows mounts mid-walk.
{ skipWindowsMountDirs: true }
)
expect(script).toContain(lookupScript)
expect(script).not.toContain('type -P')
})
@@ -132,6 +133,36 @@ describe('detectWslCommandsOnPath', () => {
})
})
it('does not veto a guest binary on an ordinary Linux mount under /mnt', async () => {
// The walk skips PATH components the guest reports as Windows drives. A name
// rule on top of that could only do harm: /mnt/d can be an ext4 volume, and
// vetoing a path the walk deliberately kept turns the false positive back
// into the #9725 false negative.
runWslProcessMock.mockResolvedValue({
environmentResolved: true,
code: 0,
stdout: '__ORCA_AGENT_PATH__claude\t/mnt/d/tools/claude\n',
stderr: '',
timedOut: false
})
await expect(detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude'])).resolves.toEqual(
new Set(['claude'])
)
})
it('still counts a genuine guest install', async () => {
runWslProcessMock.mockResolvedValue({
environmentResolved: true,
code: 0,
stdout: '__ORCA_AGENT_PATH__claude\t/home/alice/.nvm/versions/node/v20.1.0/bin/claude\n',
stderr: '',
timedOut: false
})
expect(await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude'])).toEqual(
new Set(['claude'])
)
})
describe('the detection script itself, run by a real POSIX shell', () => {
// Not a mock: the fallback is shell globbing and `[ -x ]`, which only the
// shell can be trusted about. Skipped on Windows, where /bin/sh is absent.
@@ -220,3 +251,135 @@ describe('the detection script itself, run by a real POSIX shell', () => {
expect(await runScript()).toContain('__ORCA_AGENT_PATH__orca-fake-cli')
})
})
describe('the PATH walk, run by a real POSIX shell', () => {
const itPosix = process.platform === 'win32' ? it.skip : it
itPosix('finds the guest install behind a Windows binary that shadows it', async () => {
// WSL appends the Windows PATH, so a Windows `claude` sits ahead of an
// nvm one. Rejecting the resolved path afterwards cannot resume the walk,
// so it reports "not installed" for a user who has both -- turning the
// false positive into the #9725 false negative. Skipping the component
// mid-walk is what actually finds the guest binary.
const root = mkdtempSync(join(tmpdir(), 'orca-walk-'))
try {
const win = join(root, 'winmnt/c/npm')
const nvm = join(root, 'home/.nvm/bin')
for (const [dir, body] of [
[win, 'win'],
[nvm, 'guest']
] as const) {
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'claude'), `#!/bin/sh\necho ${body}\n`)
chmodSync(join(dir, 'claude'), 0o755)
}
const script = buildPosixCommandPathLookupScript(
{ kind: 'literal', value: 'claude' },
{ skipWindowsMountDirs: true }
// Stand in for /proc/mounts, which a test host does not have.
).replace(/_orca_win_mounts=\$\([^)]*\)/, `_orca_win_mounts=${join(root, 'winmnt')}`)
const options: ExecFileSyncOptions = {
encoding: 'utf8',
env: { PATH: `${win}:${nvm}:/usr/bin:/bin` }
}
const out = String(
execFileSync('/bin/sh', ['-c', `${script}\nprintf %s "$resolved"`], options)
)
expect(out).toBe(join(nvm, 'claude'))
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
describe('the mount table read, counted against a real shell', () => {
const itPosix = process.platform === 'win32' ? it.skip : it
const runWithCountingAwk = async (commands: string[]): Promise<number> => {
const root = mkdtempSync(join(tmpdir(), 'orca-awk-'))
try {
const counter = join(root, 'count')
const bin = join(root, 'bin')
mkdirSync(bin, { recursive: true })
// A stub that records each fork, then answers as awk would on a host
// with no Windows mounts.
writeFileSync(join(bin, 'awk'), `#!/bin/sh\necho x >> ${counter}\nexit 0\n`)
chmodSync(join(bin, 'awk'), 0o755)
writeFileSync(counter, '')
runWslProcessMock.mockResolvedValue({
environmentResolved: true,
code: 0,
stdout: '',
stderr: '',
timedOut: false
})
await detectWslCommandsOnPath({ distro: 'Ubuntu' }, commands)
const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script)
const options: ExecFileSyncOptions = {
encoding: 'utf8',
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root }
}
execFileSync('/bin/sh', ['-c', script], options)
return readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length
} finally {
rmSync(root, { recursive: true, force: true })
}
}
itPosix('reads /proc/mounts once, not once per probed command', async () => {
// The prelude is embedded in the lookup script, which the caller wraps in
// `for cmd in <every agent>`. An unconditional assignment forked awk once
// per CLI -- 36 of them inside the distro against a 10s budget.
expect(await runWithCountingAwk(['claude', 'codex', 'gemini', 'opencode'])).toBe(1)
})
itPosix('still reads it once when only one command is probed', async () => {
expect(await runWithCountingAwk(['claude'])).toBe(1)
})
itPosix('applies the memoised mount list to every command, not just the first', async () => {
// Counting forks with a stub that reports NO mounts cannot see the thing
// the hoist trades correctness for: a refactor that empties or clobbers
// the variable inside the walk keeps the fork count at 1 and keeps the
// single-command test green, while every agent after the first stops
// skipping /mnt. A surviving mutant proved that gap.
const root = mkdtempSync(join(tmpdir(), 'orca-memo-'))
try {
const win = join(root, 'winmnt/c/npm')
const guest = join(root, 'home/bin')
for (const [dir, body] of [
[win, 'win'],
[guest, 'guest']
] as const) {
mkdirSync(dir, { recursive: true })
for (const name of ['claude', 'codex']) {
writeFileSync(join(dir, name), `#!/bin/sh\necho ${body}\n`)
chmodSync(join(dir, name), 0o755)
}
}
runWslProcessMock.mockResolvedValue({
environmentResolved: true,
code: 0,
stdout: '',
stderr: '',
timedOut: false
})
await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex'])
const script = String(runWslProcessMock.mock.calls.at(-1)?.[0].script).replace(
/_orca_win_mounts=\$\([^)]*\)/,
`_orca_win_mounts=${join(root, 'winmnt')}`
)
const options: ExecFileSyncOptions = {
encoding: 'utf8',
env: { PATH: `${win}:${guest}:/usr/bin:/bin`, HOME: root }
}
const out = String(execFileSync('/bin/sh', ['-c', script], options))
// BOTH must resolve behind the mount, not just the first.
expect(out).toContain(`__ORCA_AGENT_PATH__claude\t${join(guest, 'claude')}`)
expect(out).toContain(`__ORCA_AGENT_PATH__codex\t${join(guest, 'codex')}`)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
@@ -20,10 +20,14 @@ export async function detectWslCommandsOnPath(
}
const commandList = uniqueCommands.map(shellQuote).join(' ')
const lookupScript = buildPosixCommandPathLookupScript({
kind: 'shell-variable',
name: 'cmd'
})
const lookupScript = buildPosixCommandPathLookupScript(
{ kind: 'shell-variable', name: 'cmd' },
// Skip Windows mounts DURING the walk, not after it: WSL appends the
// Windows PATH, so a Windows `claude` can shadow a real guest install, and
// discarding the result afterwards reports "not installed" for a user who
// has both -- the #9725 population the fallback dirs exist to serve.
{ skipWindowsMountDirs: true }
)
// Newlines keep the loop valid in every POSIX shell used here.
const script = [
// The same fallback the preflight command runner uses: append the
+21 -10
View File
@@ -35,6 +35,7 @@ import {
} from '../git/repo'
import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
@@ -181,7 +182,8 @@ type RemoteLocalBaseRefRefreshability =
}
| {
refreshable: false
result: LocalBaseRefRefreshResult
// undefined = nothing to refresh (no local branch yet), so the caller reports no status at all.
result: LocalBaseRefRefreshResult | undefined
}
function appendWorktreeCreateWarning(current: string | undefined, next: string): string {
@@ -698,8 +700,7 @@ async function hasRemoteWorktreeBaseRef(
repoPath: string,
baseRef: string
): Promise<boolean> {
const refExists = (qualifiedRef: string) =>
hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef)
const refExists = (qualifiedRef: string) => hasCommitRefSsh(provider, repoPath, qualifiedRef)
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
if (resolvedBaseRef !== baseRef) {
return true
@@ -710,8 +711,8 @@ async function hasRemoteWorktreeBaseRef(
return hasRemoteCommitObject(provider, repoPath, baseRef)
}
// Why: hasRemoteCommitObject resolves only SHAs, not symbolic remote-tracking refs; detect those directly for the fetch-failed local fallback.
async function hasRemoteTrackingRefSsh(
// Why: hasRemoteCommitObject resolves only SHAs, not symbolic refs; resolve any qualified ref (remote-tracking or local head) directly.
async function hasCommitRefSsh(
provider: SshGitProvider,
repoPath: string,
ref: string
@@ -1262,7 +1263,7 @@ async function resolveRemoteWorktreeCreateBasePlan(
baseBranchCandidate
)
if (remoteTrackingBase) {
if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) {
if (await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref)) {
return true
}
return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate)
@@ -1313,7 +1314,7 @@ export async function prefetchRemoteWorktreeCreateBase(
}
if (basePlan.remoteTrackingBase) {
if (
(await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) ||
(await hasCommitRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) ||
!(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch))
) {
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
@@ -1333,7 +1334,7 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate(
provider: SshGitProvider,
repoPath: string,
remoteTrackingBase: RemoteTrackingBase
): Promise<LocalBaseRefRefreshResult> {
): Promise<LocalBaseRefRefreshResult | undefined> {
const evaluation = await evaluateRemoteLocalBaseRefRefreshability(
provider,
repoPath,
@@ -1393,6 +1394,16 @@ async function evaluateRemoteLocalBaseRefRefreshability(
}
}
} catch {
// Why (#15331): the probes above also fail when refs/heads/<branch> is simply absent; the relay's
// `worktree add -b` is about to create it, so there is nothing stale to warn about. Only a proven
// absence suppresses: a dropped relay connection is not evidence the branch is missing.
const presence = await probeWorktreeBaseRefPresence(
(args) => provider.exec(args, repoPath),
fullRef
)
if (presence === 'absent') {
return { refreshable: false, result: undefined }
}
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
@@ -1550,7 +1561,7 @@ export async function createRemoteWorktree(
let baseFallback: WorktreeCreateBaseFallback | undefined
if (remoteTrackingBase) {
const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh(
const hasRemoteTrackingBaseRef = await hasCommitRefSsh(
provider,
repo.path,
remoteTrackingBase.ref
@@ -1700,7 +1711,7 @@ export async function createRemoteWorktree(
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase)
} catch {
// Why: a refresh failure shouldn't block create if a usable (stale) local base ref exists; probe after registerRoot and hard-fail only when none does.
if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
if (!(await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
@@ -477,4 +477,118 @@ describe('registerWorktreeHandlers', () => {
})
expect(result.localBaseRefUpdateSuggestion).toBeUndefined()
})
// #15331: the pre-create merge-base probe fails when refs/heads/<branch> does not exist yet.
const buildMissingLocalBaseSshCase = (presence: 'absent' | 'present' | 'probe-failed') => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'for-each-ref' && args.at(-1) === 'refs/heads/main') {
if (presence === 'probe-failed') {
throw new Error('ssh: connection closed by remote host')
}
return { stdout: presence === 'present' ? 'refs/heads/main\n' : '', stderr: '' }
}
if (args[0] === 'rev-parse') {
const ref = args.at(-1) ?? ''
if (ref.startsWith('refs/heads/main')) {
return { stdout: presence === 'present' ? 'local-main\n' : '', stderr: '' }
}
// Other refs/heads probes are the new-branch conflict check; it must stay unresolvable.
return { stdout: ref.startsWith('refs/heads/') ? '' : 'remote-main\n', stderr: '' }
}
if (args[0] === 'merge-base') {
throw new Error('fatal: Not a valid object name refs/heads/main')
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/repo-improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
]),
worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
}
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: true,
workspaceDir: '/workspace'
})
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue({
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
})
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
return provider
}
it('does not report an SSH local base refresh when the local base branch does not exist', async () => {
const provider = buildMissingLocalBaseSshCase('absent')
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(provider.exec).toHaveBeenCalledWith(
['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'],
'/remote/repo'
)
expect(result.localBaseRefRefresh).toBeUndefined()
expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
})
it('keeps the SSH not-fast-forward status when the local base branch exists and diverged', async () => {
const provider = buildMissingLocalBaseSshCase('present')
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_not_fast_forward',
baseRef: 'origin/main',
localBranch: 'main'
})
expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
})
// Losing the relay mid-probe is not evidence the branch is missing.
it('keeps the SSH not-fast-forward status when the local base ref probe fails', async () => {
const provider = buildMissingLocalBaseSshCase('probe-failed')
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard'
})) as CreateWorktreeResult
expect(result.localBaseRefRefresh).toEqual({
status: 'skipped_not_fast_forward',
baseRef: 'origin/main',
localBranch: 'main'
})
expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
})
})
+176 -15
View File
@@ -18308,6 +18308,111 @@ describe('OrcaRuntimeService', () => {
expect(read.tail.join('\n')).not.toContain('Stale TUI')
})
it('rejects a provider visible frame after live non-mode-switch output advances', async () => {
type Snapshot = {
data: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}
let resolveSnapshot!: (snapshot: Snapshot) => void
const serializeProviderBuffer = vi.fn(
() =>
new Promise<Snapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const readPromise = runtime.readTerminal(terminal.handle)
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledOnce())
runtime.onPtyData('pty-1', 'live progress\r\n', 101)
resolveSnapshot({
data: '\x1b[?1049h\x1b[2J\x1b[HStale Ready screen\r\n',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
const read = await readPromise
expect(read.tail).toEqual(['shell history'])
expect(read.tail.join('\n')).not.toContain('Stale Ready screen')
})
it('rejects a full provider screen frame after live output advances', async () => {
type Snapshot = {
data: string
scrollbackAnsi: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}
let resolveSnapshot!: (snapshot: Snapshot) => void
const serializeProviderBuffer = vi
.fn()
.mockResolvedValueOnce(null)
.mockImplementationOnce(
() =>
new Promise<Snapshot>((resolve) => {
resolveSnapshot = resolve
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
serializeProviderBuffer,
hasRendererSerializer: () => false
})
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'shell history\r\n', 100)
runtime.synchronizePtyOutputSequenceFromProvider(
'pty-1',
{ value: 900, generation: 'continued' },
0
)
const [terminal] = (await runtime.listTerminals()).terminals
const readPromise = runtime.readTerminal(terminal.handle, { screen: true })
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledTimes(2))
runtime.onPtyData('pty-1', 'live progress\r\n', 101)
resolveSnapshot({
data: '\x1b[?1049h\x1b[2J\x1b[HStale full screen\r\n',
scrollbackAnsi: '',
cols: 80,
rows: 24,
seq: 900,
source: 'headless',
alternateScreen: true
})
const read = await readPromise
expect(read.source).toBe('screen-unavailable')
expect(read.tail.join('\n')).not.toContain('Stale full screen')
})
it('shares one provider snapshot between concurrent read and show calls', async () => {
type Snapshot = {
data: string
@@ -20557,16 +20662,22 @@ describe('OrcaRuntimeService', () => {
} as unknown as OrchestrationDb)
const write = vi.fn(() => true)
const kill = vi.fn(() => true)
const serializeProviderBuffer = vi.fn().mockResolvedValue({
data: '',
scrollbackAnsi:
' >_ OpenAI Codex (v0.131.0)\r\n model: gpt-5.5 high\r\n directory: /repo\r\n',
cols: 80,
rows: 24,
seq: 100,
source: 'headless' as const,
alternateScreen: false
})
const READY_SCREEN =
' >_ OpenAI Codex (v0.131.0)\r\n model: gpt-5.5 high\r\n directory: /repo\r\n'
// Why scrollbackRows-aware: a visible-only request gets the grid in `data`;
// a scrollback request gets history. Collapsing the two would let a test
// pass on evidence the caller never asked for.
const serializeProviderBuffer = vi
.fn()
.mockImplementation(async (_ptyId: string, opts?: { scrollbackRows?: number }) => ({
data: opts?.scrollbackRows === 0 ? READY_SCREEN : '',
scrollbackAnsi: opts?.scrollbackRows === 0 ? '' : READY_SCREEN,
cols: 80,
rows: 24,
seq: 100,
source: 'headless' as const,
alternateScreen: false
}))
runtime.setPtyController({
write,
kill,
@@ -20650,9 +20761,50 @@ describe('OrcaRuntimeService', () => {
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 100 })
).resolves.toMatchObject({ satisfied: true })
// Why: the ready banner stays in scrollback for the whole session, so a
// working grid must not inherit idleness from its own history (#15569 review).
serializeProviderBuffer.mockResolvedValueOnce({
data: '',
scrollbackAnsi: 'Do you trust this workspace directory?\r\n1. Yes\r\n2. No\r\n',
data: ' working on it (12s)\r\n Esc to interrupt\r\n',
scrollbackAnsi: READY_SCREEN,
cols: 80,
rows: 24,
seq: 101,
source: 'headless' as const,
alternateScreen: false
})
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 50 })
).rejects.toThrow('timeout')
const lateReadySnapshot = deferred<{
data: string
scrollbackAnsi: string
cols: number
rows: number
seq: number
source: 'headless'
alternateScreen: boolean
}>()
const snapshotSequence = runtime.getPtyOutputSequence('pty-legacy')
serializeProviderBuffer.mockImplementationOnce(() => lateReadySnapshot.promise)
const staleReadyWait = runtime.waitForTerminal(terminal.handle, {
condition: 'tui-idle',
timeoutMs: 50
})
await vi.waitFor(() => expect(serializeProviderBuffer).toHaveBeenCalledTimes(4))
runtime.onPtyData('pty-legacy', '\x1b[H', Date.now())
lateReadySnapshot.resolve({
data: READY_SCREEN,
scrollbackAnsi: '',
cols: 80,
rows: 24,
seq: snapshotSequence,
source: 'headless',
alternateScreen: false
})
await expect(staleReadyWait).rejects.toThrow('timeout')
serializeProviderBuffer.mockResolvedValueOnce({
data: 'Do you trust this workspace directory?\r\n1. Yes\r\n2. No\r\n',
scrollbackAnsi: '',
cols: 80,
rows: 24,
seq: 101,
@@ -20669,9 +20821,18 @@ describe('OrcaRuntimeService', () => {
await expect(
runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle', timeoutMs: 50 })
).rejects.toThrow('timeout')
expect(serializeProviderBuffer).toHaveBeenCalledTimes(4)
await expect(runtime.readTerminal(terminal.handle)).resolves.toMatchObject({ tail: [] })
expect(serializeProviderBuffer).toHaveBeenCalledTimes(4)
expect(serializeProviderBuffer).toHaveBeenCalledTimes(6)
// Why args, not counts: the one-shot responses above ignore their options,
// so only this asserts every idle probe asked for the visible grid alone.
expect(serializeProviderBuffer.mock.calls.slice(1)).toEqual([
['pty-legacy', { scrollbackRows: 0 }],
['pty-legacy', { scrollbackRows: 0 }],
['pty-legacy', { scrollbackRows: 0 }],
['pty-legacy', { scrollbackRows: 0 }],
['pty-legacy', { scrollbackRows: 0 }]
])
await expect(runtime.readTerminal(terminal.handle)).resolves.toBeDefined()
expect(serializeProviderBuffer).toHaveBeenCalledTimes(6)
expect(
runtime.verifyOrchestrationCompatibilityCaller({
terminalHandle: 'term_legacy',
+81 -33
View File
@@ -2987,6 +2987,16 @@ export type RuntimeRendererReloadFence = Readonly<{
recovery: 'renderer' | 'headless' | 'reloading'
}>
/** How a caller wants the provider-held screen fetched when the runtime has no
* bytes of its own. `visibleScreenOnly` narrows the result to the current grid:
* scrollback can still hold a ready banner a working agent printed minutes ago,
* which is history, not evidence of what the agent is doing now. */
type ProviderSnapshotReadOptions = {
timeoutMs?: number
retireOnTimeout?: boolean
visibleScreenOnly?: boolean
}
export class OrcaRuntimeService {
private readonly runtimeId = randomUUID()
private readonly startedAt = Date.now()
@@ -13238,6 +13248,13 @@ export class OrcaRuntimeService {
const generation = this.getPtyLifecycleGeneration(ptyId)
const scrollbackRows = Math.max(0, Math.floor(opts.scrollbackRows ?? 0))
let acquisition = this.providerBufferAcquisitionsByPtyId.get(ptyId)
// Why before the re-acquire branch: an unresponsive provider is a property of
// the process, not of the row count one caller asked for. Checking retirement
// only after re-acquiring let a wider request replace the retired entry and
// hang again; the hung call's own settle still clears it and allows recovery.
if (acquisition?.generation === generation && acquisition.timedOut) {
return null
}
if (
!acquisition ||
acquisition.generation !== generation ||
@@ -13342,7 +13359,7 @@ export class OrcaRuntimeService {
ptyId: string,
read: RuntimeTerminalRead,
opts: { cursor?: number; limit?: number } = {},
providerWait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
providerSnapshot: ProviderSnapshotReadOptions = {}
): Promise<RuntimeTerminalRead> {
if (typeof opts.cursor === 'number') {
return read
@@ -13360,7 +13377,7 @@ export class OrcaRuntimeService {
const providerLines = await this.readProviderTerminalTailLines(
ptyId,
opts.limit,
providerWait
providerSnapshot
)
if (providerLines.length > 0) {
return buildVisibleSnapshotReadFallback(read, providerLines, opts.limit)
@@ -13400,16 +13417,30 @@ export class OrcaRuntimeService {
private async readProviderTerminalTailLines(
ptyId: string,
limit: number | undefined,
wait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
snapshotOptions: ProviderSnapshotReadOptions = {}
): Promise<string[]> {
const generation = this.getPtyLifecycleGeneration(ptyId)
const lineLimit = terminalReadLimit(limit, DEFAULT_TERMINAL_READ_LIMIT)
const snapshot = await this.serializeProviderTerminalBuffer(
ptyId,
{ scrollbackRows: lineLimit },
wait
{ scrollbackRows: snapshotOptions.visibleScreenOnly ? 0 : lineLimit },
snapshotOptions
)
const data = snapshot ? `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` : ''
if (!snapshot || data.length === 0) {
if (!snapshot) {
return []
}
// Why: a cached acquisition can carry scrollback this caller did not ask for,
// so visible-only reads parse the grid itself rather than trusting the request.
if (snapshotOptions.visibleScreenOnly) {
const lines = await this.parseVisibleSnapshotLines(snapshot)
// Live bytes ordered after the provider frame make that frame stale.
return this.getPtyLifecycleGeneration(ptyId) === generation &&
this.getPtyOutputSequence(ptyId) <= snapshot.seq
? lines
: []
}
const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
if (data.length === 0) {
return []
}
const emulator = new HeadlessEmulator({
@@ -13419,7 +13450,11 @@ export class OrcaRuntimeService {
})
try {
await emulator.write(data)
return visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit))
const lines = visibleNonBlankTerminalLines(emulator.getBufferTailLines(lineLimit))
return this.getPtyLifecycleGeneration(ptyId) === generation &&
this.getPtyOutputSequence(ptyId) <= snapshot.seq
? lines
: []
} finally {
emulator.dispose()
}
@@ -13495,7 +13530,10 @@ export class OrcaRuntimeService {
}
}
const lines = await this.parseVisibleSnapshotLines(snapshot)
if (this.getPtyLifecycleGeneration(ptyId) !== generation) {
if (
this.getPtyLifecycleGeneration(ptyId) !== generation ||
this.getPtyOutputSequence(ptyId) > snapshot.seq
) {
return null
}
const visibleState: RuntimeVisibleTerminalState = {
@@ -13504,9 +13542,7 @@ export class OrcaRuntimeService {
sequence: snapshot.seq,
generation
}
if (this.getPtyOutputSequence(ptyId) <= snapshot.seq) {
this.providerVisibleStateByPtyId.set(ptyId, visibleState)
}
this.providerVisibleStateByPtyId.set(ptyId, visibleState)
return visibleState
}
@@ -18463,14 +18499,14 @@ export class OrcaRuntimeService {
async readTerminal(
handle: string,
opts: { cursor?: number; limit?: number; screen?: boolean } = {},
providerWait: { timeoutMs?: number; retireOnTimeout?: boolean } = {}
providerSnapshot: ProviderSnapshotReadOptions = {}
): Promise<RuntimeTerminalRead> {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
const read = this.readPtyTerminal(handle, pty.pty, opts)
const visibleRead = opts.screen
? await this.readRenderedScreen(pty.pty.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerWait)
: await this.withVisibleSnapshotFallback(pty.pty.ptyId, read, opts, providerSnapshot)
this.assertLiveTerminalHandleTargetsPty(handle, pty.pty.ptyId)
return labelTerminalReadSource(visibleRead)
}
@@ -18492,7 +18528,7 @@ export class OrcaRuntimeService {
}
const visibleRead = opts.screen
? await this.readRenderedScreen(leaf.ptyId, read, opts)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerWait)
: await this.withVisibleSnapshotFallback(leaf.ptyId, read, opts, providerSnapshot)
this.assertLiveTerminalHandleTargetsPty(handle, leaf.ptyId)
return labelTerminalReadSource(visibleRead)
}
@@ -35558,6 +35594,11 @@ export class OrcaRuntimeService {
}
}
/** One bounded look at the provider's screen for an adopted PTY whose retained
* readiness metadata was lost. Deliberately single-shot: it answers "is the
* screen already showing a settled prompt", and the poll above owns every
* later transition. A provider screen that is still working when this fires
* resolves through the poll, not here. */
private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void {
const snapshotTimeoutMs = Math.min(
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
@@ -35569,7 +35610,10 @@ export class OrcaRuntimeService {
{},
{
timeoutMs: snapshotTimeoutMs,
retireOnTimeout: true
retireOnTimeout: true,
// Why: the ready banner stays in scrollback for the whole session, so
// classifying history would call a working agent idle (#15569 review).
visibleScreenOnly: true
}
),
snapshotTimeoutMs,
@@ -35588,31 +35632,35 @@ export class OrcaRuntimeService {
if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) {
return
}
// Why resolve before clearing: a stale handle throws while locating the
// record, and a cleared interval would leave the waiter with no poll and
// no probe — able to end only in timeout.
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason)
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
}
const pty = this.getLivePtyForHandle(waiter.handle)
if (pty) {
this.resolveWaiter(
waiter,
blockedReason
? buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty.pty, blockedReason)
: buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty.pty)
)
return
}
const { leaf } = this.getLiveLeafForHandle(waiter.handle)
this.resolveWaiter(
waiter,
blockedReason
? buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
: buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)
)
this.resolveWaiter(waiter, result)
})
.catch(() => {})
}
private buildTuiIdleProbeResult(
handle: string,
blockedReason: RuntimeTerminalWaitBlockedReason | null
): RuntimeTerminalWait {
const pty = this.getLivePtyForHandle(handle)
if (pty) {
return blockedReason
? buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty.pty, blockedReason)
: buildPtyTerminalWaitResult(handle, 'tui-idle', pty.pty)
}
const { leaf } = this.getLiveLeafForHandle(handle)
return blockedReason
? buildTerminalWaitBlockedResult(handle, 'tui-idle', leaf, blockedReason)
: buildTerminalWaitResult(handle, 'tui-idle', leaf)
}
private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null {
for (const leaf of this.leaves.values()) {
if (leaf.ptyId !== pty.ptyId) {
+75
View File
@@ -48,11 +48,16 @@ function makeRepo(path: string, connectionId: string | null = null): Repo {
beforeEach(() => {
// Roots are shared between scans for a few seconds; each case owns its own tree.
clearSkillRootScanCache()
// Why: the Hermes root is the only home root that env vars can move outside the
// per-case tree, so a developer's real install must not leak into these scans.
vi.stubEnv('HERMES_HOME', '')
vi.stubEnv('LOCALAPPDATA', '')
vi.spyOn(console, 'info').mockImplementation(() => undefined)
})
afterEach(() => {
unavailableRootPath = null
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
@@ -367,6 +372,7 @@ describe('skill discovery', () => {
'/home/test/.config/opencode/skills',
'/home/test/.pi/agent/skills',
'/home/test/.omp/agent/skills',
'/home/test/.hermes/skills',
'/home/test/.gemini/skills',
'/home/test/.gemini/antigravity/skills',
'/home/test/.cursor/skills',
@@ -393,6 +399,9 @@ describe('skill discovery', () => {
expect(
roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.omp/agent/skills')?.owner
).toBe('omp')
expect(
roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.hermes/skills')?.owner
).toBe('hermes')
})
it('does not add runtime-owned repository paths to local scan roots', () => {
@@ -471,6 +480,72 @@ describe('skill discovery', () => {
expect(skill?.providers).toEqual(['agent-skills'])
})
it('discovers Skills installed in the Hermes home', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
const skillDir = join(home, '.hermes', 'skills', 'social-media', 'research')
await mkdir(skillDir, { recursive: true })
await writeFile(
join(skillDir, 'SKILL.md'),
['---', 'name: social-research', 'description: Research social sources.', '---', ''].join(
'\n'
)
)
const result = await discoverSkills({
homeDir: home,
cwd: join(root, 'missing-cwd')
})
expect(result.skills).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'social-research',
sourceKind: 'home',
sourceLabel: 'Hermes home',
directoryPath: skillDir,
providers: ['agent-skills']
})
])
)
expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({
owner: 'hermes',
exists: true
})
})
it('discovers Hermes Skills under a relocated HERMES_HOME profile', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
const hermesHome = join(root, 'profiles', 'coder')
const skillDir = join(hermesHome, 'skills', 'research')
await mkdir(skillDir, { recursive: true })
await writeFile(
join(skillDir, 'SKILL.md'),
['---', 'name: profile-research', 'description: Research sources.', '---', ''].join('\n')
)
vi.stubEnv('HERMES_HOME', hermesHome)
const result = await discoverSkills({
homeDir: home,
cwd: join(root, 'missing-cwd')
})
expect(result.skills).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'profile-research',
sourceLabel: 'Hermes home',
directoryPath: skillDir
})
])
)
expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({
path: join(hermesHome, 'skills'),
exists: true
})
})
it('discovers worktree .agents skill symlinks from the requested cwd', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
+2 -2
View File
@@ -34,11 +34,11 @@ const MAX_MARKDOWN_BYTES = 256 * 1024
// them for a few seconds is what bounds that fan-out.
export const SKILL_ROOT_SCAN_TTL_MS = 10_000
// Why: sized off the root formula, not a round number. One scan builds
// `12 fixed home roots + 2 per local repo (+ cwd) + plugin roots`, so a bound
// `17 fixed home roots + 7 per local repo (+ cwd) + plugin roots`, so a bound
// smaller than a single scan's root count makes that scan evict its own earlier
// entries and the cache degrades to a ~0% hit rate — exactly the walk this
// exists to prevent. The live key space is the union across targets — the fixed
// home roots plus two per repo plus two per distinct workspace cwd — so this holds
// home roots plus seven per repo plus seven per distinct workspace cwd — so this holds
// a few hundred repos with panes open, not an unbounded install. Past that the LRU
// keeps the hot home roots and the repo roots thrash, which degrades rather than
// breaks. Most repo roots do not exist, and a missing root caches as
@@ -60,11 +60,16 @@ function readdirCountUnder(path: string): number {
beforeEach(() => {
clearSkillRootScanCache()
readdirPaths.length = 0
// Why: the Hermes root is the one home root an env var can move outside the
// fixture, so a developer with a real Hermes install would score it `present`.
vi.stubEnv('HERMES_HOME', '')
vi.stubEnv('LOCALAPPDATA', '')
vi.spyOn(console, 'info').mockImplementation(() => undefined)
})
afterEach(() => {
clearSkillRootScanCache()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
@@ -180,7 +185,7 @@ describe('bounded concurrent skill discovery', () => {
const line = String(info.mock.calls.at(0)?.at(0))
// `present` is the signal that separates "big tree" from "big root set", and
// is not derivable from the other counts.
expect(line).toContain('[skills] scan roots=23 present=3 walked=23 skills=3')
expect(line).toContain('[skills] scan roots=24 present=3 walked=24 skills=3')
expect(line).toContain('home-claude')
expect(line).not.toContain(home)
expect(line).not.toContain(tmpdir())
+13 -1
View File
@@ -11,7 +11,11 @@ import type { AgentType } from '../../shared/agent-status-types'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots'
import {
resolveDefaultHermesSkillsRoot,
resolveEnvironmentHermesSkillsRoot,
resolveEnvironmentSkillProviderRoots
} from './skill-provider-runtime-roots'
export type SkillScanRoot = Omit<SkillDiscoverySource, 'exists' | 'skippedReason'>
type SkillDiscoveryPathApi = Pick<typeof posix, 'basename' | 'join'>
@@ -77,6 +81,13 @@ export function buildSkillDiscoverySources(
const cwd = args.cwd ?? process.cwd()
const providerRootOverrides =
args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots())
// Why: HERMES_HOME moves the whole profile tree, so the default home path
// finds nothing for `hermes -p <profile>`. Only this process's own host can
// read it — a custom pathApi means the home belongs to another host, whose
// Hermes install is POSIX-shaped even when this process runs on Windows.
const hermesSkillsRoot = args.pathApi
? pathApi.join(home, '.hermes', 'skills')
: (resolveEnvironmentHermesSkillsRoot() ?? resolveDefaultHermesSkillsRoot({ homeDir: home }))
const roots: SkillScanRoot[] = [
source(
'home-codex',
@@ -144,6 +155,7 @@ export function buildSkillDiscoverySources(
['agent-skills'],
'omp'
),
source('home-hermes', 'Hermes home', hermesSkillsRoot, 'home', ['agent-skills'], 'hermes'),
source(
'home-prime-agent',
'Prime Agent home',
@@ -1,6 +1,6 @@
import type { WslResult } from '../wsl/wsl-runner'
import { posix as pathPosix } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const runWslProcessMock = vi.hoisted(() => vi.fn())
@@ -19,10 +19,15 @@ function wslResult(stdout: string): WslResult {
describe('WSL Claude plugin skill discovery', () => {
beforeEach(() => runWslProcessMock.mockReset())
afterEach(() => vi.unstubAllEnvs())
it('reads enabled plugin metadata and scans the selected install inside the distro', async () => {
const homeDir = '/home/alice'
const cwd = '/work/orca'
// Why: a Windows host's own Hermes location says nothing about the distro's,
// so neither variable may reach the posix scan script.
vi.stubEnv('HERMES_HOME', 'C:\\Users\\alice\\hermes')
vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\alice\\AppData\\Local')
const pluginId = 'compound-engineering@compound-engineering-plugin'
const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3'
const installed = JSON.stringify({
@@ -58,6 +63,8 @@ describe('WSL Claude plugin skill discovery', () => {
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
const scanScript = runWslProcessMock.mock.calls[1]?.[0].script as string
expect(scanScript).toContain('/home/alice/.hermes/skills')
expect(scanScript).not.toContain('AppData')
expect(scanScript).toContain(`${installPath}/skills`)
expect(result.skills).toEqual([
expect.objectContaining({
@@ -1,6 +1,8 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
resolveDefaultHermesSkillsRoot,
resolveEnvironmentHermesSkillsRoot,
resolveEnvironmentSkillProviderRoots,
resolveWslGrokSkillProviderRoot,
withClaudeSkillProviderRoot
@@ -30,6 +32,71 @@ describe('skill provider runtime roots', () => {
})
})
it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => {
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe(
join('/srv', 'hermes', 'skills')
)
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull()
expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull()
})
it('defaults the Hermes skills root to the home dotfolder off Windows', () => {
expect(
resolveDefaultHermesSkillsRoot({
homeDir: join('/users', 'alice'),
platform: 'linux',
env: { LOCALAPPDATA: join('/local') },
directoryExists: () => true
})
).toBe(join('/users', 'alice', '.hermes', 'skills'))
})
it('defaults the Hermes skills root under LOCALAPPDATA on Windows', () => {
const resolved = resolveDefaultHermesSkillsRoot({
homeDir: join('/users', 'alice'),
platform: 'win32',
env: { LOCALAPPDATA: join('/local') },
directoryExists: (candidate) => candidate === join('/local', 'hermes')
})
expect(resolved).toBe(join('/local', 'hermes', 'skills'))
})
it('keeps a pre-LOCALAPPDATA Windows dotfolder install discoverable', () => {
const resolved = resolveDefaultHermesSkillsRoot({
homeDir: join('/users', 'alice'),
platform: 'win32',
env: { LOCALAPPDATA: join('/local') },
directoryExists: (candidate) => candidate === join('/users', 'alice', '.hermes')
})
expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills'))
})
it('prefers the LOCALAPPDATA tree on Windows when both layouts exist', () => {
const resolved = resolveDefaultHermesSkillsRoot({
homeDir: join('/users', 'alice'),
platform: 'win32',
env: { LOCALAPPDATA: join('/local') },
directoryExists: () => true
})
expect(resolved).toBe(join('/local', 'hermes', 'skills'))
})
it('falls back to the dotfolder when Windows exposes no usable LOCALAPPDATA', () => {
const seen: string[] = []
const resolved = resolveDefaultHermesSkillsRoot({
homeDir: join('/users', 'alice'),
platform: 'win32',
env: { LOCALAPPDATA: 'relative\\local' },
directoryExists: (candidate) => {
seen.push(candidate)
return true
}
})
expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills'))
// A rejected LOCALAPPDATA must not cost a stat: there is nothing to compare.
expect(seen).toEqual([])
})
it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => {
await expect(
resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n')
@@ -1,3 +1,4 @@
import { statSync } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { toWindowsWslPath } from '../../shared/wsl-paths'
import { runWslProcess } from '../wsl/wsl-runner'
@@ -37,6 +38,48 @@ export function resolveEnvironmentSkillProviderRoots(
}
}
// Why: `HERMES_HOME` relocates a whole Hermes profile tree (`hermes -p coder`),
// and the rest of Orca already treats it as authoritative. Hermes is not an
// install provider, so it stays out of `SkillProviderRootOverrides`.
export function resolveEnvironmentHermesSkillsRoot(
env: NodeJS.ProcessEnv = process.env
): string | null {
const hermesHome = normalizedRoot(env.HERMES_HOME)
return hermesHome ? join(hermesHome, 'skills') : null
}
function isExistingDirectory(candidate: string): boolean {
return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true
}
/**
* The Hermes home when `HERMES_HOME` is unset. Windows installs land in
* `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile, so the POSIX
* `~/.hermes` default discovers nothing there. The dotfolder still wins on
* Windows when it exists and the LOCALAPPDATA tree does not, which is the
* pre-LOCALAPPDATA install Hermes itself keeps honouring rather than orphaning.
*/
export function resolveDefaultHermesSkillsRoot(input: {
homeDir: string
env?: NodeJS.ProcessEnv
platform?: NodeJS.Platform
directoryExists?: (candidate: string) => boolean
}): string {
const dotfolderHome = join(input.homeDir, '.hermes')
if ((input.platform ?? process.platform) !== 'win32') {
return join(dotfolderHome, 'skills')
}
const localAppData = normalizedRoot((input.env ?? process.env).LOCALAPPDATA)
if (!localAppData) {
return join(dotfolderHome, 'skills')
}
const localAppDataHome = join(localAppData, 'hermes')
const directoryExists = input.directoryExists ?? isExistingDirectory
return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome)
? join(dotfolderHome, 'skills')
: join(localAppDataHome, 'skills')
}
export function withClaudeSkillProviderRoot(
roots: SkillProviderRootOverrides,
claudeConfigDirectory: string | null | undefined
+30 -1
View File
@@ -159,13 +159,42 @@ describe('scripts', () => {
// command line at 32767, so argv would fail to spawn at all; stdin still
// runs it, which is the lesser loss.
seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT)
const script = `echo ${'x'.repeat(9_000)}`
const script = `echo ${'x'.repeat(31_000)}`
await runWslProcess({ loginPath: 'preferred', shell: 'bash', script })
const spec = runProcessMock.mock.calls.at(-1)?.[0]
expect(spec.input).toBe(script)
expect(lastArgv().slice(-3)).toEqual(['bash', '-s', '--'])
})
it('flips to stdin when the login PATH, not the script, is what overflows', async () => {
// The perverse band a script-only threshold created: with a long enough
// PATH spliced in as `PATH=...`, a 7,999-char hook went to argv and failed
// to spawn, while the SAME hook at 8,001 chars flipped to stdin and ran.
// Size decided how a hook behaved, in the wrong direction.
seedWslGuestEnvironmentForTests(undefined, {
...ENVIRONMENT,
path: '/opt/x/bin:'.repeat(2_500)
})
// Deliberately UNDER any script-only threshold: 7k of script with 27k of
// PATH overflows the line, and the old rule sent exactly this to argv.
const script = `echo ${'x'.repeat(7_000)}`
expect(script.length).toBeLessThan(8_000)
await runWslProcess({ loginPath: 'preferred', shell: 'bash', script })
const spec = runProcessMock.mock.calls.at(-1)?.[0]
expect(spec.input).toBe(script)
expect(spec.args.join(' ')).not.toContain(script)
})
it('charges quoting, so a quote-dense script cannot slip over the real cap', async () => {
// libuv escapes every `"` and doubles backslash runs, so a script's cost is
// more than its length. Counting length alone put a quote-heavy ~26KB
// script on argv and over the 32767 ceiling.
seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT)
const script = '"'.repeat(26_000)
await runWslProcess({ loginPath: 'preferred', shell: 'bash', script })
expect(runProcessMock.mock.calls.at(-1)?.[0].input).toBe(script)
})
it('keeps an ordinary-sized script in argv', async () => {
seedWslGuestEnvironmentForTests(undefined, ENVIRONMENT)
const script = `echo ${'x'.repeat(100)}`
+31 -5
View File
@@ -169,8 +169,14 @@ function withGuestCwd(cwd: string | undefined, argv: readonly string[]): string[
* failing to spawn at all and accepting the stdin caveat. Degrading beats
* failing: a large script that also reads stdin was already broken, while a
* large script that does not now works where it would have died.
*
* Measured on the WHOLE command line, not on the script alone. A login PATH is
* itself a few KB and is spliced in as `PATH=...`, so a script-only threshold
* produced a perverse band: with a long enough PATH, a 7,999-char hook went to
* argv and failed to spawn while the same hook at 8,001 chars flipped to stdin
* and ran. Size decided how a hook behaved, in the wrong direction.
*/
const MAX_ARGV_SCRIPT_CHARS = 8_000
const MAX_COMMAND_LINE_CHARS = 30_000
/** `<shell> -c`/`-s` for a script, otherwise the program itself. */
function guestCommandArgv(spec: WslSpec, delivery: 'argv' | 'stdin'): string[] {
@@ -184,6 +190,19 @@ function guestCommandArgv(spec: WslSpec, delivery: 'argv' | 'stdin'): string[] {
: [shell, '-c', spec.script, '--', ...(spec.args ?? [])]
}
/**
* What `CreateProcess` will count.
*
* libuv escapes every `"` and doubles a backslash run before a quote, so a
* quote-dense script costs more than its length. Charging one extra character
* per `"` or `\\` keeps the estimate on the safe side of the cap; an earlier
* version claimed to over-count and in fact under-counted, which put a
* quote-heavy ~26KB script on argv and over the real limit.
*/
function commandLineLength(args: readonly string[]): number {
return args.reduce((total, arg) => total + arg.length + 3 + (arg.match(/["\\]/g)?.length ?? 0), 0)
}
/** Shell-free argv, with the cached environment applied when one is available. */
function buildGuestArgv(
environment: WslGuestEnvironment | null,
@@ -229,11 +248,18 @@ export async function runWslProcess(spec: WslSpec): Promise<WslResult> {
// the probe most often fails *because* the distro is slow, so the fallback
// would hit the hazard exactly when it is worst. Run shell-free with the
// distro's default PATH instead: degraded, never blocking.
// Resolved once: argv shape and stdin payload must agree, and computing it
// in both places invites them to drift.
// Build the argv form first and measure it: the env prefix is part of the
// budget, so only the finished line can say whether argv fits. Resolved once
// here because the argv shape and the stdin payload must agree.
const argvForm = buildGuestArgv(environment, spec, 'argv')
// Measure what is actually spawned: `wsl.exe` and `-d <distro> --exec` are
// prepended after this point and are part of the same budget.
const fullLine = [resolveWslExecutablePath(), ...buildWslExecArgs(spec.distro, argvForm)]
const delivery: 'argv' | 'stdin' =
spec.script !== undefined && spec.script.length > MAX_ARGV_SCRIPT_CHARS ? 'stdin' : 'argv'
const argv = buildGuestArgv(environment, spec, delivery)
spec.script !== undefined && commandLineLength(fullLine) > MAX_COMMAND_LINE_CHARS
? 'stdin'
: 'argv'
const argv = delivery === 'argv' ? argvForm : buildGuestArgv(environment, spec, 'stdin')
// One budget for the whole call: the probe used to run on its own 10s timer
// ahead of the timed leg, so a 5s caller could wait 15s.
+110 -24
View File
@@ -31,12 +31,16 @@ describe('addWorktreeOp', () => {
it('writes durable branch base config after creating an SSH new-branch worktree', async () => {
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
await addWorktreeOp(git, {
repoPath: '/repo',
branchName: 'feature/test',
targetDir: '/repo-feature',
base: 'origin/main'
})
await addWorktreeOp(
git,
{
repoPath: '/repo',
branchName: 'feature/test',
targetDir: '/repo-feature',
base: 'origin/main'
},
'linux'
)
expect(git.mock.calls.map((call) => call[0])).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
@@ -75,13 +79,17 @@ describe('addWorktreeOp', () => {
it('does not write branch base config when checking out an existing SSH branch', async () => {
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
await addWorktreeOp(git, {
repoPath: '/repo',
branchName: 'feature/test',
targetDir: '/repo-feature',
base: 'origin/main',
checkoutExistingBranch: true
})
await addWorktreeOp(
git,
{
repoPath: '/repo',
branchName: 'feature/test',
targetDir: '/repo-feature',
base: 'origin/main',
checkoutExistingBranch: true
},
'linux'
)
expect(git.mock.calls.map((call) => call[0])).toEqual([
['worktree', 'add', '/repo-feature', 'feature/test']
@@ -91,11 +99,15 @@ describe('addWorktreeOp', () => {
it('does not write branch base config when SSH creation has no base', async () => {
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
await addWorktreeOp(git, {
repoPath: '/repo',
branchName: 'feature/no-base',
targetDir: '/repo-feature'
})
await addWorktreeOp(
git,
{
repoPath: '/repo',
branchName: 'feature/no-base',
targetDir: '/repo-feature'
},
'linux'
)
expect(git.mock.calls.map((call) => call[0])).toEqual([
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
@@ -103,6 +115,76 @@ describe('addWorktreeOp', () => {
])
})
it('enables long paths when the SSH execution host is Windows', async () => {
// Why: only the host's OS matters — a macOS client can drive a Windows SSH host,
// which hits the same MAX_PATH ceiling (issue #15785).
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
await addWorktreeOp(
git,
{
repoPath: 'C:\\repo',
branchName: 'feature/test',
targetDir: 'C:\\repo-feature',
checkoutExistingBranch: true
},
'win32'
)
expect(git.mock.calls.map((call) => call[0])).toEqual([
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test']
])
})
it('keeps --no-checkout ahead of -b once the long-path prefix is present', async () => {
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
await addWorktreeOp(
git,
{
repoPath: 'C:\\repo',
branchName: 'feature/test',
targetDir: 'C:\\repo-feature',
noCheckout: true
},
'win32'
)
expect(git.mock.calls[0][0]).toEqual([
'-c',
'core.longpaths=true',
'worktree',
'add',
'--no-track',
'--no-checkout',
'-b',
'feature/test',
'C:\\repo-feature'
])
})
it('omits the long-path option on a WSL UNC target on a Windows SSH host', async () => {
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
await addWorktreeOp(
git,
{
repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo',
branchName: 'feature/test',
targetDir: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
checkoutExistingBranch: true
},
'win32'
)
expect(git.mock.calls[0][0]).toEqual([
'worktree',
'add',
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
'feature/test'
])
})
it('warns and unsets stale branch base config when SSH base persistence fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const git = vi.fn<GitExec>(async (args) => {
@@ -113,12 +195,16 @@ describe('addWorktreeOp', () => {
})
await expect(
addWorktreeOp(git, {
repoPath: '/repo',
branchName: 'feature/test',
targetDir: '/repo-feature',
base: 'origin/main'
})
addWorktreeOp(
git,
{
repoPath: '/repo',
branchName: 'feature/test',
targetDir: '/repo-feature',
base: 'origin/main'
},
'linux'
)
).resolves.toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
+13 -4
View File
@@ -1,5 +1,6 @@
import * as path from 'node:path'
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
import { windowsLongPathGitArgs } from '../shared/windows-long-path-git-args'
import type { GitExec } from './git-handler-ops'
export { removeWorktreeOp } from './git-handler-worktree-remove'
export { readRelayWorktreeList } from './git-handler-worktree-list'
@@ -28,7 +29,12 @@ async function persistRelayWorktreeCreationBase(
}
}
export async function addWorktreeOp(git: GitExec, params: Record<string, unknown>): Promise<void> {
export async function addWorktreeOp(
git: GitExec,
params: Record<string, unknown>,
// Why: only the execution host's OS matters here — the client may be macOS while the SSH host is Windows.
platform: NodeJS.Platform = process.platform
): Promise<void> {
const repoPath = params.repoPath as string
const branchName = params.branchName as string
const targetDir = params.targetDir as string
@@ -62,11 +68,14 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
})
: undefined
// Why: a Windows SSH host hits the same MAX_PATH ceiling as a local Windows checkout.
const longPathArgs = windowsLongPathGitArgs(targetDir, platform)
const args = checkoutExistingBranch
? ['worktree', 'add', targetDir, branchName]
: ['worktree', 'add', '--no-track', '-b', branchName, targetDir]
? [...longPathArgs, 'worktree', 'add', targetDir, branchName]
: [...longPathArgs, 'worktree', 'add', '--no-track', '-b', branchName, targetDir]
if (!checkoutExistingBranch && noCheckout) {
args.splice(3, 0, '--no-checkout')
// Why: offset by the global-option prefix so --no-checkout still lands before -b.
args.splice(longPathArgs.length + 3, 0, '--no-checkout')
}
if (effectiveBase) {
args.push(effectiveBase)
@@ -630,3 +630,50 @@ describe('AgentsPane', () => {
await secondWrite
})
})
describe('empty agent detection must not cost the saved default (#15256)', () => {
const withEmptyDetection = <T,>(run: () => T): T => {
const previous = detectedAgentsMock.detectedIds
detectedAgentsMock.detectedIds = []
try {
return run()
} finally {
detectedAgentsMock.detectedIds = previous
}
}
/** The rendered `<button>` whose label contains `label`. */
const pillMarkup = (markup: string, label: string): string => {
const chunk = markup.split('<button').find((part) => part.includes(label))
expect(chunk, `no pill labelled ${label}`).toBeDefined()
return String(chunk)
}
it('does not present Auto as the active choice while an agent is stored', () => {
// The Auto pill's handler writes null. Rendering it pressed while
// `defaultTuiAgent: "claude"` is stored made the already-selected pill
// destructive: one click erased the setting, and later successful
// detection did not bring it back.
const markup = withEmptyDetection(() =>
renderPane({ ...getDefaultSettings('/tmp'), defaultTuiAgent: 'claude' })
)
expect(pillMarkup(markup, 'Auto')).toContain('aria-pressed="false"')
})
it('still offers the stored agent so the choice can be kept', () => {
// With zero detected there were no agent pills at all, so the stored value
// was invisible and unrecoverable through the UI.
const markup = withEmptyDetection(() =>
renderPane({ ...getDefaultSettings('/tmp'), defaultTuiAgent: 'claude' })
)
expect(markup).toContain('Saved as your default, but not detected right now')
})
it('keeps a Refresh control reachable when nothing is detected', () => {
// Refresh lived inside the Installed section, which only renders when at
// least one agent was found -- gone in exactly the state needing a retry.
const markup = withEmptyDetection(() => renderPane(getDefaultSettings('/tmp')))
expect(markup).toContain('No agents detected')
expect(markup).toContain('Refresh')
})
})
@@ -661,14 +661,21 @@ type DefaultAgentPillProps = {
active: boolean
onClick: () => void
children: React.ReactNode
title?: string
}
function DefaultAgentPill({ active, onClick, children }: DefaultAgentPillProps): React.JSX.Element {
function DefaultAgentPill({
active,
onClick,
children,
title
}: DefaultAgentPillProps): React.JSX.Element {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
title={title}
className={cn(
'inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50',
active
@@ -800,15 +807,27 @@ export function AgentsPane({
(a) => detectedIds !== null && !detectedIds.has(a.id)
)
// Why: 'blank' is an explicit no-agent preference, not an auto fallback,
// so the Auto pill should only light up when the default is null OR when a
// selected agent id is no longer detected on PATH.
const isAutoDefault =
defaultAgent === null ||
(defaultAgent !== 'blank' &&
(!detectedIds?.has(defaultAgent) || !isTuiAgentEnabled(defaultAgent, disabledAgents)))
// Why only `=== null`: the pill's own handler writes null, so lighting it up
// for a *stored* agent that merely is not detected right now made the
// already-checked pill destructive -- one click on it erased
// `defaultTuiAgent` (#15256). Detection is a transient fact; the stored value
// is not, and this control reports the stored value.
const isAutoDefault = defaultAgent === null
const isBlankDefault = defaultAgent === 'blank'
// Why show an undetected default: when detection comes back empty there were
// no agent pills at all, so the stored choice was invisible AND unrecoverable
// -- nothing to click to put it back. Keeping it listed means a failed or
// slow probe cannot quietly cost the user their setting.
const storedDefaultAgent =
defaultAgent !== null && defaultAgent !== 'blank'
? getAgentCatalog().find((agent) => agent.id === defaultAgent)
: undefined
const defaultAgentPills =
storedDefaultAgent && !enabledDetectedAgents.some((agent) => agent.id === storedDefaultAgent.id)
? [...enabledDetectedAgents, storedDefaultAgent]
: enabledDetectedAgents
return (
<div className="space-y-8">
<section className="space-y-4">
@@ -836,13 +855,22 @@ export function AgentsPane({
{isBlankDefault && <Check className="size-3.5" />}
</DefaultAgentPill>
{enabledDetectedAgents.map((agent) => {
{defaultAgentPills.map((agent) => {
const isActive = defaultAgent === agent.id
const isUndetected = detectedIds !== null && !detectedIds.has(agent.id)
return (
<DefaultAgentPill
key={agent.id}
active={isActive}
onClick={() => setDefault(agent.id)}
title={
isUndetected
? translate(
'auto.components.settings.AgentsPane.storedDefaultUndetected',
'Saved as your default, but not detected right now'
)
: undefined
}
>
<AgentIcon agent={agent.id} size={14} />
{agent.label}
@@ -877,6 +905,33 @@ export function AgentsPane({
<AgentPermissionsSetting mode={agentPermissionMode} onChange={saveAgentPermissionMode} />
{detectedAgents.length === 0 && detectedIds !== null && !detectionFailed && (
<div className="flex items-center justify-between gap-3 rounded-md border border-dashed border-border/50 px-3 py-3 text-sm text-muted-foreground">
{/* Why here and not only inside Installed: that section renders under
`detectedAgents.length > 0`, so the only Refresh control vanished
in precisely the state that needs a retry (#15256). */}
<span>
{translate(
'auto.components.settings.AgentsPane.noAgentsDetected',
'No agents detected. If one is installed, the probe may have timed out.'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-7 shrink-0 gap-1.5 text-xs"
>
<RefreshCw className={cn('size-3', isRefreshing && 'animate-spin')} />
{isRefreshing
? translate('auto.components.settings.AgentsPane.c9b33eb5c0', 'Refreshing…')
: translate('auto.components.settings.AgentsPane.0d9e293a02', 'Refresh')}
</Button>
</div>
)}
{detectedAgents.length > 0 && (
<section className="space-y-3">
<SettingsSubsectionHeader
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('PetStatusSegment layout', () => {
it('does not reserve fixed right padding on the pet menu trigger', () => {
const source = readFileSync(join(__dirname, 'PetStatusSegment.tsx'), 'utf8')
// Why: the old pr-[6.5rem] reserved ~104px of empty space after the label and
// shoved neighboring status-bar segments left; unit-guard so CI catches a
// reintroduction without needing Electron Playwright.
expect(source).not.toMatch(/pr-\[\d+(?:\.\d+)?rem\]/)
expect(source).toMatch(/className="group inline-flex items-center cursor-pointer pl-1 py-0\.5"/)
})
})
@@ -116,7 +116,7 @@ function PetStatusSegmentInner(): React.JSX.Element {
<DropdownMenuTrigger asChild>
<button
type="button"
className="group inline-flex items-center cursor-pointer pl-1 pr-[6.5rem] py-0.5"
className="group inline-flex items-center cursor-pointer pl-1 py-0.5"
aria-label={translate(
'auto.components.status.bar.PetStatusSegment.aec479308a',
'Pet menu'
@@ -60,6 +60,7 @@ import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from '../terminal/running-terminal-clo
import CodexRestartChip from '../CodexRestartChip'
import { MobileDriverOverlay } from './MobileDriverOverlay'
import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast'
import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import TerminalContextMenu from './TerminalContextMenu'
import TerminalPaneHeaderOverlay, { type PaneTitleOverlayRect } from './TerminalPaneHeaderOverlay'
@@ -100,6 +101,8 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
import { useNotificationDispatch } from './use-notification-dispatch'
import { connectPanePty } from './pty-connection'
import type { PaneProcessExit, PtyConnectionDeps } from './pty-connection-types'
import { resolveTerminalProcessExitRestartStartup } from './terminal-process-exit-restart'
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers'
import {
@@ -399,6 +402,15 @@ function TerminalPane(
const [agentSessionContinuation, setAgentSessionContinuation] =
useState<AgentSessionContinuationRequest | null>(null)
const [terminalError, setTerminalError] = useState<string | null>(null)
const [paneProcessExitsByPaneId, setPaneProcessExitsByPaneId] = useState<
Record<number, PaneProcessExit>
>({})
const handlePaneProcessDied = useCallback((processExit: PaneProcessExit) => {
setPaneProcessExitsByPaneId((current) => ({
...current,
[processExit.paneId]: processExit
}))
}, [])
const [ptyRecoveryStatesByPaneId, setPtyRecoveryStatesByPaneId] = useState<
Record<number, VisiblePtyRecoveryState>
>({})
@@ -1368,6 +1380,7 @@ function TerminalPane(
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPaneProcessDied: handlePaneProcessDied,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore((store) => store.consumeSuppressedPtyExit),
@@ -1535,7 +1548,10 @@ function TerminalPane(
}, [])
const handleRestartCodexPane = useCallback(
(paneId: number) => {
(
paneId: number,
restartStartup: PtyConnectionDeps['startup'] = CODEX_ACCOUNT_RESTART_STARTUP
) => {
const manager = managerRef.current
const pane = manager?.getPanes().find((candidate) => candidate.id === paneId)
if (!manager || !pane) {
@@ -1565,7 +1581,7 @@ function TerminalPane(
tabId,
worktreeId,
cwd,
startup: CODEX_ACCOUNT_RESTART_STARTUP,
startup: restartStartup,
mountFollowsTerminalPark: false,
paneTransportsRef,
paneMode2031Ref,
@@ -1577,6 +1593,7 @@ function TerminalPane(
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPaneProcessDied: handlePaneProcessDied,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit: useAppStore.getState().consumeSuppressedPtyExit,
@@ -1607,6 +1624,7 @@ function TerminalPane(
clearTabPtyId,
cwd,
dispatchNotification,
handlePaneProcessDied,
markWorktreeUnread,
markTerminalTabUnread,
markTerminalPaneUnread,
@@ -1627,6 +1645,36 @@ function TerminalPane(
]
)
const clearPaneProcessExit = useCallback((paneId: number) => {
setPaneProcessExitsByPaneId((current) => {
if (current[paneId] === undefined) {
return current
}
const next = { ...current }
delete next[paneId]
return next
})
}, [])
const handleRestartExitedPane = useCallback(
(processExit: PaneProcessExit) => {
clearPaneProcessExit(processExit.paneId)
handleRestartCodexPane(
processExit.paneId,
resolveTerminalProcessExitRestartStartup(processExit)
)
},
[clearPaneProcessExit, handleRestartCodexPane]
)
const handleCloseExitedPane = useCallback(
(paneId: number) => {
clearPaneProcessExit(paneId)
executeClosePane(paneId)
},
[clearPaneProcessExit, executeClosePane]
)
// Why leaf bindings are a dep: a parked or deferred tab mounts with no
// transport, so a queued restart has no ptyId to match on the mount pass. The
// reconnected PTY rewrites this map when it binds — `ptyIdsByTabId` does not,
@@ -3031,6 +3079,22 @@ function TerminalPane(
onRestartDaemon={() => daemonActions.setPending('restart')}
/>
) : null}
{isActive
? managedPanes.map((pane) => {
const processExit = paneProcessExitsByPaneId[pane.id]
return processExit
? createPortal(
<TerminalProcessExitOverlay
processExit={processExit}
onRestart={() => handleRestartExitedPane(processExit)}
onClose={() => handleCloseExitedPane(pane.id)}
/>,
pane.container,
`process-exit-${pane.id}`
)
: null
})
: null}
{/* Why: portal into the pane so the banner stacks above the xterm canvas (sibling mount painted under WebGL). */}
{showSshReconnectOverlay && sshReconnectTargetId && sshReconnectStatus
? managedPanes.map((pane) =>
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay'
describe('TerminalProcessExitOverlay', () => {
afterEach(cleanup)
it('explains the Git Bash limit and exposes recovery actions', () => {
const onRestart = vi.fn()
const onClose = vi.fn()
render(
<TerminalProcessExitOverlay
processExit={{
paneId: 1,
exitCode: 1,
reason: 'git-bash-console-capacity',
startup: null
}}
onRestart={onRestart}
onClose={onClose}
/>
)
expect(screen.getByRole('alert').textContent).toContain('128-console limit')
fireEvent.click(screen.getByRole('button', { name: 'Restart' }))
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onRestart).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('preserves the exit code for other shell failures', () => {
render(
<TerminalProcessExitOverlay
processExit={{ paneId: 1, exitCode: 7, reason: 'process-failed', startup: null }}
onRestart={vi.fn()}
onClose={vi.fn()}
/>
)
expect(screen.getByRole('alert').textContent).toContain('exit code 7')
})
})
@@ -0,0 +1,61 @@
import { RotateCw, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { PaneProcessExit } from './pty-connection-types'
export function TerminalProcessExitOverlay({
processExit,
onRestart,
onClose
}: {
processExit: PaneProcessExit
onRestart: () => void
onClose: () => void
}): React.JSX.Element {
const capacityError = processExit.reason === 'git-bash-console-capacity'
const title = capacityError
? translate(
'auto.components.terminal.pane.TerminalProcessExitOverlay.capacityTitle',
'Git Bash console limit reached'
)
: translate(
'auto.components.terminal.pane.TerminalProcessExitOverlay.failedTitle',
'Terminal exited'
)
const detail = capacityError
? translate(
'auto.components.terminal.pane.TerminalProcessExitOverlay.capacityDetail',
'Git Bash reached its 128-console limit. Close unused Git Bash terminals, then restart this terminal.'
)
: translate(
'auto.components.terminal.pane.TerminalProcessExitOverlay.failedDetail',
'The shell process ended with exit code {{code}}. Its output is preserved.'
).replace('{{code}}', String(processExit.exitCode))
return (
<div className="pointer-events-none absolute inset-0 z-40 flex items-end justify-center p-4">
<div
role="alert"
className="pointer-events-auto flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xs"
>
<div className="space-y-1">
<div className="text-sm font-medium text-foreground">{title}</div>
<div className="text-xs text-muted-foreground">{detail}</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
<X />
{translate('auto.components.terminal.pane.TerminalProcessExitOverlay.close', 'Close')}
</Button>
<Button type="button" size="sm" onClick={onRestart}>
<RotateCw />
{translate(
'auto.components.terminal.pane.TerminalProcessExitOverlay.restart',
'Restart'
)}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { createGitBashConsoleCapacityDetector } from './git-bash-console-capacity'
describe('Git Bash console capacity detection', () => {
it('recognizes the MSYS fatal message across PTY chunks', () => {
const detector = createGitBashConsoleCapacityDetector()
detector.observe('console device allocation failure - too many consoles ')
detector.observe('in use, max consoles is 128')
expect(detector.detected()).toBe(true)
})
it('ignores unrelated shell failures', () => {
const detector = createGitBashConsoleCapacityDetector()
detector.observe('bash: command not found')
expect(detector.detected()).toBe(false)
})
})
@@ -0,0 +1,23 @@
const GIT_BASH_CONSOLE_CAPACITY_MARKER = 'too many consoles in use, max consoles is 128'
export type GitBashConsoleCapacityDetector = {
observe: (data: string) => void
detected: () => boolean
}
export function createGitBashConsoleCapacityDetector(): GitBashConsoleCapacityDetector {
let tail = ''
let matched = false
return {
observe(data) {
if (matched || data.length === 0) {
return
}
const candidate = (tail + data).toLowerCase()
matched = candidate.includes(GIT_BASH_CONSOLE_CAPACITY_MARKER)
tail = candidate.slice(-(GIT_BASH_CONSOLE_CAPACITY_MARKER.length - 1))
},
detected: () => matched
}
}
@@ -1,6 +1,8 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
import { flushAsyncTicks } from './pty-connection-test-async'
import {
LEAF_1,
LEAF_2,
@@ -129,6 +131,38 @@ function createDeps(overrides: Record<string, unknown> = {}) {
return buildPaneConnectionDeps(() => mockStoreState, overrides)
}
function installSleepingCodexResumeState(restoredPtyId?: string) {
const paneKey = makePaneKey('tab-1', LEAF_1)
const launchConfig = {
agentCommand: "codex '--model' 'gpt-5'",
agentArgs: '--model gpt-5',
agentEnv: { CODEX_PROFILE: 'captured' }
}
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ...(restoredPtyId ? { ptyId: restoredPtyId } : {}) }]
},
settings: { ...mockStoreState.settings, agentCmdOverrides: {} },
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: {
[paneKey]: {
paneKey,
tabId: 'tab-1',
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'finish the task',
state: 'working',
capturedAt: 1,
updatedAt: 1,
launchConfig
}
}
} as StoreState
return launchConfig
}
describe('connectPanePty', () => {
beforeEach(() => {
vi.resetModules()
@@ -388,6 +422,185 @@ describe('connectPanePty', () => {
expect(manager.closePane).not.toHaveBeenCalled()
})
it('keeps a failed local terminal visible after user input', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const transport = createMockTransport('tab-pty')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps({ onPaneProcessDied: vi.fn() })
connectPanePty(pane as never, manager as never, deps as never)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string, exitCode?: number) => void)
| undefined
sendTerminalInputThroughPane(pane, 'agent startup\r')
onPtyExit?.('tab-pty', 1)
expect(deps.onPaneProcessDied).toHaveBeenCalledWith({
paneId: 1,
exitCode: 1,
startup: null,
reason: 'process-failed'
})
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
expect(manager.closePane).not.toHaveBeenCalled()
})
it('keeps a failed local split pane visible', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({ onPaneProcessDied: vi.fn() })
connectPanePty(createPane(1) as never, manager as never, deps as never)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string, exitCode?: number) => void)
| undefined
onPtyExit?.('tab-pty', 1)
expect(deps.onPaneProcessDied).toHaveBeenCalledWith({
paneId: 1,
exitCode: 1,
startup: null,
reason: 'process-failed'
})
expect(manager.closePane).not.toHaveBeenCalled()
})
it('classifies the Git Bash capacity failure before retaining its pane', async () => {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'tab-pty'
})
transportFactoryQueue.push(transport)
const manager = createManager(1)
const startup = { command: 'codex --resume session-1' }
const deps = createDeps({ onPaneProcessDied: vi.fn(), startup })
connectPanePty(createPane(1) as never, manager as never, deps as never)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string, exitCode?: number) => void)
| undefined
capturedDataCallback.current?.(
'console device allocation failure - too many consoles in use, max consoles is 128'
)
onPtyExit?.('tab-pty', 1)
expect(deps.onPaneProcessDied).toHaveBeenCalledWith({
paneId: 1,
exitCode: 1,
startup,
reason: 'git-bash-console-capacity'
})
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('retains the cold-restore resume startup when its replacement hits capacity', async () => {
const { connectPanePty } = await import('./pty-connection')
const callbacks: ConnectCallbacks[] = []
const transport = createMockTransport('resume-pty')
transport.connect.mockImplementation(async (options: { callbacks: ConnectCallbacks }) => {
callbacks.push(options.callbacks)
return 'resume-pty'
})
transportFactoryQueue.push(transport)
const launchConfig = installSleepingCodexResumeState()
const deps = createDeps({
startup: { command: 'codex stale-startup' },
onPaneProcessDied: vi.fn()
})
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
callbacks[0]?.onData?.('too many consoles in use, max consoles is 128')
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string, exitCode?: number) => void)
| undefined
onPtyExit?.('resume-pty', 1)
expect(deps.onPaneProcessDied).toHaveBeenCalledWith({
paneId: 1,
exitCode: 1,
startup: expect.objectContaining({
command: expect.stringContaining("'resume' 'codex-session-1'"),
launchConfig,
resumeProviderSession: { key: 'session_id', id: 'codex-session-1' },
launchAgent: 'codex',
showSessionRestoredBanner: true
}),
reason: 'git-bash-console-capacity'
})
})
it('does not carry capacity detection into a cold-restore replacement', async () => {
const { connectPanePty } = await import('./pty-connection')
const callbacks: ConnectCallbacks[] = []
let currentPtyId = 'lost-pty'
const transport = createMockTransport(currentPtyId)
transport.getPtyId.mockImplementation(() => currentPtyId)
transport.connect.mockImplementation(
async (options: { sessionId?: string; callbacks: ConnectCallbacks }) => {
callbacks.push(options.callbacks)
if (options.sessionId) {
options.callbacks.onData?.('too many consoles in use, max consoles is 128')
return { id: currentPtyId, sessionExpired: true }
}
currentPtyId = 'resume-pty'
return currentPtyId
}
)
transportFactoryQueue.push(transport)
installSleepingCodexResumeState('lost-pty')
const deps = createDeps({
onPaneProcessDied: vi.fn(),
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks(30)
expect(callbacks).toHaveLength(2)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string, exitCode?: number) => void)
| undefined
onPtyExit?.('resume-pty', 1)
expect(deps.onPaneProcessDied).toHaveBeenCalledWith({
paneId: 1,
exitCode: 1,
startup: null,
reason: 'process-failed'
})
})
it('does not retain a failed direct-SSH terminal locally', async () => {
const { connectPanePty } = await import('./pty-connection')
mockStoreState = {
...mockStoreState,
repos: [{ ...mockStoreState.repos[0], connectionId: 'ssh-1' }]
} as StoreState
const transport = createMockTransport('tab-pty')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps({ onPaneProcessDied: vi.fn() })
connectPanePty(createPane(1) as never, manager as never, deps as never)
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string, exitCode?: number) => void)
| undefined
onPtyExit?.('tab-pty', 1)
expect(deps.onPaneProcessDied).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty')
})
it('tears down the sole terminal when a reattached (not freshly spawned) PTY exits', async () => {
// Why: reattach/coldRestore skip onPtySpawn, so a now-dead previously-live session must still route through onPtyExitRef; the keep-mounted guard is only for brand-new shells.
const { connectPanePty } = await import('./pty-connection')
@@ -17,36 +17,45 @@ import type { PtyTransportRecoveryState } from './pty-transport-types'
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery'
export type PtyPaneStartup = {
command: string
/** Renderer-delivered startup input for callers that need xterm paste
* semantics before the submit Enter. */
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
/** Telemetry payload for `agent_started`. Forwarded to `pty:spawn`
* so main fires the event only after the spawn succeeds. */
telemetry?: EventProps<'agent_started'>
/** Initial prompt-start status for agents that lack native prompt hooks. */
initialAgentStatus?: { agent: TuiAgent; prompt: string }
/** Show the restored-session banner when this startup command mounts. */
showSessionRestoredBanner?: boolean
/** Initial startup may be paired with a setup split that changes its grid. */
waitForSetupSplitDirection?: SetupSplitDirection
} | null
export type PaneProcessExit = {
paneId: number
exitCode: number
reason: 'git-bash-console-capacity' | 'process-failed'
startup: PtyPaneStartup
}
export type PtyConnectionDeps = {
tabId: string
worktreeId: string
cwd?: string
startup?: {
command: string
/** Renderer-delivered startup input for callers that need xterm paste
* semantics before the submit Enter. */
delivery?: 'terminal-paste'
startupCommandDelivery?: StartupCommandDelivery
env?: Record<string, string>
envToDelete?: string[]
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
agentArgsOverride?: string | null
draftPrompt?: string
sessionOptions?: Record<string, SessionOptionValue>
/** Telemetry payload for `agent_started`. Forwarded to `pty:spawn`
* so main fires the event only after the spawn succeeds. */
telemetry?: EventProps<'agent_started'>
/** Initial prompt-start status for agents that lack native prompt hooks. */
initialAgentStatus?: { agent: TuiAgent; prompt: string }
/** Show the restored-session banner when this startup command mounts. */
showSessionRestoredBanner?: boolean
/** Initial startup may be paired with a setup split that changes its grid. */
waitForSetupSplitDirection?: SetupSplitDirection
} | null
startup?: PtyPaneStartup
restoredLeafId?: string | null
restoredPtyIdByLeafId?: Record<string, string>
/** Park intent sampled at render time, before the host disposes the tab's
@@ -66,6 +75,7 @@ export type PtyConnectionDeps = {
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onAgentExitedRef: React.RefObject<(leafId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
onPaneProcessDied?: (processExit: PaneProcessExit) => void
onPtyRecoveryStateRef?: React.RefObject<
(paneId: number, state: PtyTransportRecoveryState | null) => void
>
@@ -59,7 +59,11 @@ import {
shouldReconcileMissingSession,
type HasPty
} from './terminal-dead-session-reconcile'
import type { PtyConnectionDeps } from './pty-connection-types'
import type { PtyConnectionDeps, PtyPaneStartup } from './pty-connection-types'
import {
createGitBashConsoleCapacityDetector,
type GitBashConsoleCapacityDetector
} from './git-bash-console-capacity'
import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state'
import {
consumeCommittedPtyShutdownExit,
@@ -2659,7 +2663,11 @@ export function connectPanePty(
})
return claimKey
}
const onExit = (ptyId: string, opts: { preserveRendererBinding?: boolean } = {}): void => {
const onExit = (
ptyId: string,
exitCode = 0,
opts: { preserveRendererBinding?: boolean } = {}
): void => {
if (handledExitPtyId === ptyId) {
return
}
@@ -2667,7 +2675,7 @@ export function connectPanePty(
// Why: the transport emits exit once; replay it only after a verified commit so rollback keeps renderer state retryable.
deferPtyShutdownExit(ptyId, (settlement) => {
if (settlement === 'committed') {
onExit(ptyId, { preserveRendererBinding: true })
onExit(ptyId, exitCode, { preserveRendererBinding: true })
}
})
return
@@ -2681,6 +2689,7 @@ export function connectPanePty(
// Why: an old transport can deliver a late exit after this pane has
// rebound to a replacement PTY; only clear ownership for the exited id.
handledExitPtyId = ptyId
processExitStateByPtyId.delete(ptyId)
if (!preserveRendererBinding) {
deps.clearTabPtyId(deps.tabId, ptyId)
}
@@ -2689,6 +2698,8 @@ export function connectPanePty(
return
}
handledExitPtyId = ptyId
const processExitState = processExitStateByPtyId.get(ptyId) ?? currentProcessExitState
processExitStateByPtyId.delete(ptyId)
agentCompletionCoordinator.dispose()
dropSideEffectFactConsumer()
// Why: main clears gate state on PTY exit too; this only resets the
@@ -2765,6 +2776,17 @@ export function connectPanePty(
return
}
manager.setPaneGpuRendering(pane.id, true)
const failedLocalProcess = !connectionId && runtimeEnvironmentId === null && exitCode !== 0
if (failedLocalProcess && deps.onPaneProcessDied) {
const gitBashConsoleCapacityFailure = processExitState.detector.detected()
deps.onPaneProcessDied({
paneId: pane.id,
exitCode,
startup: gitBashConsoleCapacityFailure ? processExitState.startup : null,
reason: gitBashConsoleCapacityFailure ? 'git-bash-console-capacity' : 'process-failed'
})
return
}
const panes = manager.getPanes()
if (panes.length <= 1) {
// Why: a worktree's sole newborn terminal can die on shell startup — e.g.
@@ -3030,6 +3052,25 @@ export function connectPanePty(
}
const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector()
type ProcessExitState = {
startup: PtyPaneStartup
detector: GitBashConsoleCapacityDetector
}
const createProcessExitState = (startup: PtyPaneStartup): ProcessExitState => ({
startup,
detector: createGitBashConsoleCapacityDetector()
})
let currentProcessExitState = createProcessExitState(paneStartup)
const processExitStateByPtyId = new Map<string, ProcessExitState>()
const bindProcessExitState = (ptyId: string, replacedPtyId?: string): void => {
const state =
(replacedPtyId ? processExitStateByPtyId.get(replacedPtyId) : undefined) ??
currentProcessExitState
processExitStateByPtyId.set(ptyId, state)
if (replacedPtyId && replacedPtyId !== ptyId) {
processExitStateByPtyId.delete(replacedPtyId)
}
}
const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => {
if (!ptyId || isRemoteRuntimePtyId(ptyId)) {
// Why: remote-runtime PTYs use a relay path outside main's local
@@ -3047,6 +3088,7 @@ export function connectPanePty(
sampleVisibleForegroundAgent?: boolean
} = {}
): void => {
bindProcessExitState(ptyId, options.replacePtyId)
if (activePanePtyBinding && activePanePtyBinding !== ptyId) {
reportPanePtyVisibility(activePanePtyBinding, false)
}
@@ -5205,6 +5247,20 @@ export function connectPanePty(
: {})
}
: undefined
const toProcessExitStartup = (
startup: PendingStartupCommand | ColdRestoreAgentResumeStartup | null
): PtyPaneStartup =>
startup && 'launchConfig' in startup && 'agent' in startup
? {
command: startup.command,
env: startup.env,
launchConfig: startup.launchConfig,
resumeProviderSession: startup.resumeProviderSession,
launchToken: startup.launchToken,
launchAgent: startup.agent,
showSessionRestoredBanner: true
}
: startup
const startFreshColdRestoreAgentResume = (
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(),
options: FreshSpawnOptions = {}
@@ -5368,7 +5424,11 @@ export function connectPanePty(
: window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null)
transportConnectInFlightSince = Date.now()
const outputCallbacks = captureTransportOutputCallbacks(reportError)
const effectiveStartup = startupOverride === undefined ? paneStartup : startupOverride
const outputCallbacks = captureTransportOutputCallbacks(
reportError,
toProcessExitStartup(coldRestoreOverride ?? effectiveStartup)
)
const spawnedRaw = transport.connect({
url: '',
cols,
@@ -5873,7 +5933,10 @@ export function connectPanePty(
scheduleReplayDataDrain()
}
const captureTransportOutputCallbacks = (onError: (message: string) => void) => {
const captureTransportOutputCallbacks = (
onError: (message: string) => void,
startup: PtyPaneStartup
) => {
// Why: a new stream generation cannot inherit an old replay's pending
// destination-grid fit or keep its live-data waiter open.
pendingHiddenSnapshotFit?.cancel()
@@ -5881,6 +5944,8 @@ export function connectPanePty(
pendingReattachFit?.cancel()
pendingReattachFit = null
const generation = (transportStreamGeneration += 1)
const processExitState = createProcessExitState(startup)
currentProcessExitState = processExitState
const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration
return {
generation,
@@ -5907,6 +5972,7 @@ export function connectPanePty(
},
onData: (data: string, meta?: PtyDataMeta): void => {
if (isCurrent()) {
processExitState.detector.observe(data)
dataCallback(data, meta, generation)
}
},
@@ -8312,6 +8378,7 @@ export function connectPanePty(
})
return false
}
bindProcessExitState(ptyId)
setPanePtyFitBinding(ptyId)
reportPanePtyVisibility(ptyId, deps.isVisibleRef.current)
registerSideEffectFactConsumerForPty(ptyId)
@@ -8722,7 +8789,7 @@ export function connectPanePty(
authoritativeReattachGeneration += 1
clearPaneMode2031State()
clearHiddenOutputRestoreState()
const outputCallbacks = captureTransportOutputCallbacks(reportError)
const outputCallbacks = captureTransportOutputCallbacks(reportError, null)
transport.attach({
existingPtyId: ptyId,
callbacks: outputCallbacks.callbacks
@@ -8905,16 +8972,19 @@ export function connectPanePty(
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
clearPaneMode2031State()
clearHiddenOutputRestoreState()
const outputCallbacks = captureTransportOutputCallbacks((message) => {
if (isSshSessionExpiredError(message)) {
expiredReattachError = true
return
}
if (!isCapturedDirectSshReattachCurrent(pendingSessionId)) {
return
}
reportError(message)
})
const outputCallbacks = captureTransportOutputCallbacks(
(message) => {
if (isSshSessionExpiredError(message)) {
expiredReattachError = true
return
}
if (!isCapturedDirectSshReattachCurrent(pendingSessionId)) {
return
}
reportError(message)
},
toProcessExitStartup(coldRestoreStartup ?? paneStartup)
)
beginReattachLiveDataDeferral(outputCallbacks.generation)
transportConnectInFlightSince = Date.now()
const reattachPromise = transport.connect({
@@ -9152,16 +9222,19 @@ export function connectPanePty(
let expiredReattachError = false
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
const outputCallbacks = captureTransportOutputCallbacks((message) => {
if (isSshSessionExpiredError(message)) {
expiredReattachError = true
return
}
if (!isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
return
}
reportError(message)
})
const outputCallbacks = captureTransportOutputCallbacks(
(message) => {
if (isSshSessionExpiredError(message)) {
expiredReattachError = true
return
}
if (!isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
return
}
reportError(message)
},
toProcessExitStartup(coldRestoreStartup ?? paneStartup)
)
beginReattachLiveDataDeferral(outputCallbacks.generation)
transportConnectInFlightSince = Date.now()
const reattachPromise = transport.connect({
@@ -9303,7 +9376,7 @@ export function connectPanePty(
try {
clearPaneMode2031State()
clearHiddenOutputRestoreState()
const outputCallbacks = captureTransportOutputCallbacks(reportError)
const outputCallbacks = captureTransportOutputCallbacks(reportError, null)
transport.attach({
existingPtyId: attachPtyId,
cols,
@@ -9358,7 +9431,7 @@ export function connectPanePty(
}
clearPaneMode2031State()
clearHiddenOutputRestoreState()
const outputCallbacks = captureTransportOutputCallbacks(reportError)
const outputCallbacks = captureTransportOutputCallbacks(reportError, null)
transport.attach({
existingPtyId: spawnedPtyId,
cols,
@@ -165,7 +165,7 @@ describe('createIpcPtyTransport', () => {
onExit?.({ id: 'pty-detached', code: 0 })
expect(onPtyExit).toHaveBeenCalledWith('pty-detached')
expect(onPtyExit).toHaveBeenCalledWith('pty-detached', 0)
expect(transport.getPtyId()).toBeNull()
})
@@ -54,7 +54,7 @@ describe('createIpcPtyTransport', () => {
onExit?.({ id: 'pty-new', code: 0 })
expect(onPtyExit).toHaveBeenCalledWith('pty-new')
expect(onPtyExit).toHaveBeenCalledWith('pty-new', 0)
expect(transport.getPtyId()).toBeNull()
expect(transport.isConnected()).toBe(false)
})
@@ -127,7 +127,7 @@ describe('createIpcPtyTransport', () => {
// Exit handler should still work (exit handlers are kept alive)
onExit?.({ id: 'pty-1', code: -1 })
expect(onPtyExit).toHaveBeenCalledWith('pty-1')
expect(onPtyExit).toHaveBeenCalledWith('pty-1', -1)
})
it('marks a host reversible-stop exit before delivering it to the pane', async () => {
@@ -254,7 +254,7 @@ describe('createIpcPtyTransport', () => {
expect(onDataCallback).toHaveBeenCalledWith('final output')
expect(onExitCallback).toHaveBeenCalledWith(17)
expect(onDisconnect).toHaveBeenCalledTimes(1)
expect(onPtyExit).toHaveBeenCalledWith(sessionId)
expect(onPtyExit).toHaveBeenCalledWith(sessionId, 17)
expect(transport.isConnected()).toBe(false)
})
@@ -245,7 +245,7 @@ export type IpcPtyTransportOptions = {
projectRuntime?: ProjectExecutionRuntimeResolution
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
telemetry?: EventProps<'agent_started'>
onPtyExit?: (ptyId: string) => void
onPtyExit?: (ptyId: string, exitCode?: number) => void
onTitleChange?: (title: string, rawTitle: string) => void
onPtySpawn?: (ptyId: string) => void
/** Rebind an existing pane after its provider replaces the PTY identity. */
@@ -756,7 +756,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
unregisterPtyHandlers(id)
storedCallbacks.onExit?.(code)
storedCallbacks.onDisconnect?.()
onPtyExit?.(id)
onPtyExit?.(id, code)
}
ptyExitHandlers.set(id, exitHandler)
ownedExitHandlers.set(id, exitHandler)
@@ -348,6 +348,89 @@ describe('createRemoteRuntimePtyTransport', () => {
)
})
it('degrades a Kimi resume to a legacy launch when the host predates the resume capability', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'repo1::/remote/wt',
command: "kimi '--session' 'session_431324d7'",
launchAgent: 'kimi',
resumeProviderSession: { key: 'session_id', id: 'session_431324d7' },
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111'
})
await transport.connect({ url: '', callbacks: {} })
// Why: the beforeEach host advertises host-authority.v1 but not the Kimi gate, and it answers
// the widened ensureAgentSession enum with invalid_argument — not a fallback code. Only the
// per-agent probe keeps the pane alive.
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'terminal.ensureAgentSession' })
)
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.create',
params: expect.objectContaining({ command: "kimi '--session' 'session_431324d7'" })
})
)
})
it('claims the Kimi provider session on a host that advertises the resume capability', async () => {
runtimeCall.mockImplementation(async (args: { method?: string }) =>
args.method === 'status.get'
? {
id: 'rpc-status',
ok: true,
result: {
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: ['agent-session.host-authority.v1', 'agent-session.kimi-resume.v1']
},
_meta: { runtimeId: 'runtime-remote' }
}
: {
id: 'rpc-create',
ok: true,
result: {
terminal: {
handle: 'term-remote',
worktreeId: 'repo1::/remote/wt',
title: null,
surface: 'background'
}
},
_meta: { runtimeId: 'runtime-remote' }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'repo1::/remote/wt',
command: "kimi '--session' 'session_431324d7'",
launchAgent: 'kimi',
resumeProviderSession: { key: 'session_id', id: 'session_431324d7' },
tabId: 'tab-1',
leafId: '11111111-1111-4111-8111-111111111111'
})
await transport.connect({ url: '', callbacks: {} })
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.ensureAgentSession',
params: expect.objectContaining({
agent: 'kimi',
providerSession: { key: 'session_id', id: 'session_431324d7' }
})
})
)
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({
method: 'terminal.create',
params: expect.objectContaining({ command: expect.any(String) })
})
)
})
it('treats an explicitly killed remote session as normal retirement', async () => {
runtimeCall.mockImplementation(async (args: { method?: string }) =>
args.method === 'terminal.create'
@@ -19,10 +19,8 @@ import type {
RuntimeTerminalResolvePane,
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import {
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { agentResumeHostAuthorityCapability } from '../../runtime/agent-resume-host-authority-capability'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
iterateTerminalInputChunks
@@ -2104,14 +2102,17 @@ export function createRemoteRuntimePtyTransport(
createEnvironmentId,
connectLifecycleEpoch
)
const resumeHostAuthorityCapability = resumeProviderSessionToSend
? agentResumeHostAuthorityCapability(launchAgentToSend)
: undefined
const created = launchAgentToSend
? agentSessionRequiresHostAuthorityReplay
? await hostAuthorityCreate()
: await runRemoteAgentSessionLaunch<RemoteAgentSessionLaunchResult | null>({
environmentId: createEnvironmentId,
hostAuthority: hostAuthorityCreate,
...(resumeProviderSessionToSend && launchAgentToSend === 'omp'
? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY }
...(resumeHostAuthorityCapability
? { hostAuthorityCapability: resumeHostAuthorityCapability }
: {}),
legacy: legacyCreate
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalProcessExitRestartStartup } from './terminal-process-exit-restart'
describe('terminal process exit restart', () => {
it('retries the original startup after a Git Bash capacity failure', () => {
const startup = { command: 'codex --resume session-1' }
expect(
resolveTerminalProcessExitRestartStartup({
paneId: 1,
exitCode: 1,
reason: 'git-bash-console-capacity',
startup
})
).toBe(startup)
})
it('restarts a shell when a capacity failure had no startup request', () => {
expect(
resolveTerminalProcessExitRestartStartup({
paneId: 1,
exitCode: 1,
reason: 'git-bash-console-capacity',
startup: null
})
).toBeNull()
})
it('restarts other process failures as a shell', () => {
expect(
resolveTerminalProcessExitRestartStartup({
paneId: 1,
exitCode: 7,
reason: 'process-failed',
startup: { command: 'codex' }
})
).toBeNull()
})
})
@@ -0,0 +1,7 @@
import type { PaneProcessExit, PtyPaneStartup } from './pty-connection-types'
export function resolveTerminalProcessExitRestartStartup(
processExit: PaneProcessExit
): PtyPaneStartup {
return processExit.reason === 'git-bash-console-capacity' ? processExit.startup : null
}
@@ -26,6 +26,7 @@ import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/termina
import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent'
import { useAppStore } from '@/store'
import type { DirectSshPaneRetryAttemptId } from '@/store/slices/direct-ssh-terminal-recovery'
import type { PaneProcessExit } from './pty-connection-types'
import {
createFilePathLinkProvider,
getTerminalFileOpenHint,
@@ -292,6 +293,7 @@ type UseTerminalPaneLifecycleDeps = {
onPtyExitRef: React.RefObject<(ptyId: string) => void>
onAgentExitedRef: React.RefObject<(leafId: string) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
onPaneProcessDied?: (processExit: PaneProcessExit) => void
onPtyRecoveryStateRef?: React.RefObject<
(paneId: number, state: PtyTransportRecoveryState | null) => void
>
@@ -698,6 +700,7 @@ export function useTerminalPaneLifecycle({
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPaneProcessDied,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit,
@@ -940,6 +943,7 @@ export function useTerminalPaneLifecycle({
onPtyExitRef,
onAgentExitedRef,
onPtyErrorRef,
onPaneProcessDied,
onPtyRecoveryStateRef,
clearTabPtyId,
consumeSuppressedPtyExit,
+11 -1
View File
@@ -2934,6 +2934,14 @@
"e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.",
"sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue."
},
"TerminalProcessExitOverlay": {
"capacityTitle": "Git Bash console limit reached",
"capacityDetail": "Git Bash reached its 128-console limit. Close unused Git Bash terminals, then restart this terminal.",
"failedTitle": "Terminal exited",
"failedDetail": "The shell process ended with exit code {{code}}. Its output is preserved.",
"close": "Close",
"restart": "Restart"
},
"TerminalPane": {
"ac112e9036": "Remove title",
"f984ab2a30": "Remove pane title: {{value0}}",
@@ -6240,7 +6248,9 @@
"3f1bdf3cb4": "Environment text is too large to parse safely.",
"codexSessionSource": "Codex home to import from",
"codexSessionSourceInfo": "About importing Codex history",
"codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex."
"codexSessionSourceTooltip": "Orca runs Codex in an isolated home. Point this at your existing Codex home to import that session history. Empty uses ~/.codex.",
"storedDefaultUndetected": "Saved as your default, but not detected right now",
"noAgentsDetected": "No agents detected. If one is installed, the probe may have timed out."
},
"AppIconSelector": {
"d5a112dc9b": "Next icon",
+8
View File
@@ -2740,6 +2740,14 @@
"5c8ce20be6": "Si esto persiste, por favor",
"cc6d997c65": "Reinicia el servicio del terminal desde aquí para borrar el estado obsoleto."
},
"TerminalProcessExitOverlay": {
"capacityTitle": "Se alcanzó el límite de consolas de Git Bash",
"capacityDetail": "Git Bash alcanzó su límite de 128 consolas. Cierra las terminales de Git Bash que no uses y reinicia esta terminal.",
"failedTitle": "La terminal se cerró",
"failedDetail": "El proceso del shell terminó con el código de salida {{code}}. Se conservó su salida.",
"close": "Cerrar",
"restart": "Reiniciar"
},
"TerminalPane": {
"ac112e9036": "Quitar título",
"f984ab2a30": "Eliminar título del panel: {{value0}}",
+8
View File
@@ -2740,6 +2740,14 @@
"5c8ce20be6": "この状態が続く場合は、",
"cc6d997c65": "ここからターミナルデーモンを再起動して、古いデーモン状態をクリアします。"
},
"TerminalProcessExitOverlay": {
"capacityTitle": "Git Bash のコンソール上限に達しました",
"capacityDetail": "Git Bash が 128 個のコンソール上限に達しました。不要な Git Bash ターミナルを閉じてから、このターミナルを再起動してください。",
"failedTitle": "ターミナルが終了しました",
"failedDetail": "シェルプロセスが終了コード {{code}} で終了しました。出力は保持されています。",
"close": "閉じる",
"restart": "再起動"
},
"TerminalPane": {
"ac112e9036": "タイトルを削除",
"f984ab2a30": "ペインのタイトルを削除: {{value0}}",
+8
View File
@@ -2742,6 +2742,14 @@
"5c8ce20be6": "문제가 계속되면",
"cc6d997c65": "오래된 데몬 상태를 지우려면 여기에서 terminal 데몬을 다시 시작하세요."
},
"TerminalProcessExitOverlay": {
"capacityTitle": "Git Bash 콘솔 한도에 도달했습니다",
"capacityDetail": "Git Bash가 128개 콘솔 한도에 도달했습니다. 사용하지 않는 Git Bash 터미널을 닫은 후 이 터미널을 다시 시작하세요.",
"failedTitle": "터미널이 종료되었습니다",
"failedDetail": "셸 프로세스가 종료 코드 {{code}}로 끝났습니다. 출력은 보존됩니다.",
"close": "닫기",
"restart": "다시 시작"
},
"TerminalPane": {
"ac112e9036": "제목 삭제",
"f984ab2a30": "창 제목 제거: {{value0}}",
+8
View File
@@ -2752,6 +2752,14 @@
"5c8ce20be6": "如果这种情况持续存在,请",
"cc6d997c65": "从此处重新启动终端守护进程以清除失效的守护进程状态。"
},
"TerminalProcessExitOverlay": {
"capacityTitle": "已达到 Git Bash 控制台上限",
"capacityDetail": "Git Bash 已达到 128 个控制台的上限。关闭未使用的 Git Bash 终端,然后重新启动此终端。",
"failedTitle": "终端已退出",
"failedDetail": "Shell 进程以退出代码 {{code}} 结束。输出已保留。",
"close": "关闭",
"restart": "重新启动"
},
"TerminalPane": {
"ac112e9036": "删除标题",
"f984ab2a30": "删除窗格标题:{{value0}}",
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import {
buildAiVaultResumeCopyCommandForWorktree,
buildAiVaultResumeStartupForWorktree
} from './ai-vault-resume-command'
vi.mock('@/lib/new-workspace', () => ({
CLIENT_PLATFORM: 'darwin'
}))
type ResumableAgentState = Pick<
AppState,
| 'activeRepoId'
| 'activeWorktreeId'
| 'folderWorkspaces'
| 'projectGroups'
| 'projects'
| 'repos'
| 'settings'
| 'worktreesByRepo'
>
function makeState(): ResumableAgentState {
return {
activeRepoId: 'repo-1',
activeWorktreeId: 'repo-1::worktree-1',
folderWorkspaces: [],
projectGroups: [],
repos: [{ id: 'repo-1', path: '/Users/ada/repo' }],
projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }],
settings: {
agentDefaultArgs: { claude: '', codex: '' },
agentDefaultEnv: { claude: {}, codex: {} }
},
worktreesByRepo: {
'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }]
}
} as unknown as ResumableAgentState
}
const KIMI_SESSION = {
agent: 'kimi' as const,
sessionId: 'session_431324d7-2165-42f0-9ecd-9f93437b3201',
cwd: '/Users/ada/repo/packages/api',
codexHome: null
}
describe('AI Vault resume for Kimi', () => {
it('keeps the cd prefix on the copied resume line', () => {
// Why: Kimi sessions are work-dir-scoped — resuming from the worktree root instead of the
// session's own cwd fails with "created under a different directory".
expect(
buildAiVaultResumeCopyCommandForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session: KIMI_SESSION
})
).toBe(
"cd '/Users/ada/repo/packages/api' && kimi '--yolo' '--session' 'session_431324d7-2165-42f0-9ecd-9f93437b3201'"
)
})
it('takes the resumable-agent startup plan so the pane claims the provider session', () => {
// Why: the plan branch applies Kimi's default launch args, so a resumed pane starts with the
// same permission flag Orca gives a fresh one.
expect(
buildAiVaultResumeStartupForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session: KIMI_SESSION
})
).toMatchObject({
command: "kimi '--yolo' '--session' 'session_431324d7-2165-42f0-9ecd-9f93437b3201'",
cwd: '/Users/ada/repo/packages/api',
launchConfig: { agentCommand: "kimi '--yolo'" },
providerSession: {
key: 'session_id',
id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201'
}
})
})
})
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { RESUMABLE_TUI_AGENTS } from '../../../shared/agent-session-resume'
import {
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
RUNTIME_CAPABILITIES
} from '../../../shared/protocol-version'
import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability'
describe('agentResumeHostAuthorityCapability', () => {
it('gates Kimi resume behind its own capability', () => {
expect(agentResumeHostAuthorityCapability('kimi')).toBe(
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY
)
})
it('keeps the OMP resume-path gate', () => {
expect(agentResumeHostAuthorityCapability('omp')).toBe(
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY
)
})
it('leaves agents shipped with host authority on the generic probe', () => {
expect(agentResumeHostAuthorityCapability('codex')).toBeUndefined()
expect(agentResumeHostAuthorityCapability(null)).toBeUndefined()
expect(agentResumeHostAuthorityCapability(undefined)).toBeUndefined()
})
it('advertises the Kimi resume capability from the host', () => {
expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY)
})
it('pins the gate for every resumable agent so a new member is a deliberate decision', () => {
// Why: silently defaulting a newly resumable agent to the generic probe is the exact skew
// failure this module exists to prevent — the mapping must be reviewed, not inherited.
expect(
Object.fromEntries(
RESUMABLE_TUI_AGENTS.map((agent) => [agent, agentResumeHostAuthorityCapability(agent)])
)
).toEqual({
claude: undefined,
codex: undefined,
gemini: undefined,
antigravity: undefined,
opencode: undefined,
pi: undefined,
'mimo-code': undefined,
droid: undefined,
grok: undefined,
devin: undefined,
'prime-agent': undefined,
copilot: undefined,
omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY
})
})
})
@@ -0,0 +1,45 @@
import type { ResumableTuiAgent } from '../../../shared/agent-session-resume'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
type RuntimeCapability
} from '../../../shared/protocol-version'
// Why: every agent added to RESUMABLE_TUI_AGENTS after agent-session.host-authority.v1 widens the
// host's ensureAgentSession enum. An older host answers the unknown member with invalid_argument,
// which runRemoteAgentSessionLaunch does not treat as a fallback signal, so the pane dies instead
// of degrading to a legacy launch. Probing the agent's own capability keeps version skew safe.
// The exhaustive Record makes the next RESUMABLE_TUI_AGENTS entry a compile error until its own
// gate — or a deliberate `undefined` — is declared here.
const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = {
// These shipped inside agent-session.host-authority.v1's enum, so the generic probe covers them.
claude: undefined,
codex: undefined,
gemini: undefined,
antigravity: undefined,
opencode: undefined,
pi: undefined,
'mimo-code': undefined,
droid: undefined,
grok: undefined,
devin: undefined,
// Ungated on purpose: prime-agent's host-side status hooks and its enum entry both shipped in
// a1f61ef, so no host emits a prime-agent provider session without also accepting the member.
'prime-agent': undefined,
// Ungated to match how main shipped copilot resume; gating it is its own change.
copilot: undefined,
omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY
} satisfies Record<ResumableTuiAgent, RuntimeCapability | undefined>
export function agentResumeHostAuthorityCapability(
agent: TuiAgent | null | undefined
): RuntimeCapability | undefined {
if (!agent) {
return undefined
}
return (
RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT as Partial<Record<TuiAgent, RuntimeCapability>>
)[agent]
}
@@ -17,6 +17,7 @@ vi.mock('./runtime-rpc-client', () => ({
import { RuntimeRpcCallError } from './runtime-rpc-client'
import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch'
import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability'
describe('remote agent-session launch routing', () => {
beforeEach(() => {
@@ -44,6 +45,25 @@ describe('remote agent-session launch routing', () => {
expect(legacy).not.toHaveBeenCalled()
})
it('falls back to legacy when an older host lacks the Kimi resume capability', async () => {
const hostAuthority = vi.fn().mockResolvedValue('structured')
const legacy = vi.fn().mockResolvedValue('legacy')
mocks.supportsCapability.mockResolvedValue(false)
// Why: an old host rejects the widened agent enum with invalid_argument, which is not a
// fallback code — so the probe, not the error handler, has to keep the pane alive.
await expect(
runRemoteAgentSessionLaunch({
environmentId: 'env-1',
hostAuthority,
hostAuthorityCapability: agentResumeHostAuthorityCapability('kimi'),
legacy
})
).resolves.toBe('legacy')
expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-session.kimi-resume.v1')
expect(hostAuthority).not.toHaveBeenCalled()
})
it('preserves the exact legacy path when the capability is absent', async () => {
const hostAuthority = vi.fn().mockResolvedValue('structured')
const legacy = vi.fn().mockResolvedValue('legacy')
@@ -191,6 +191,76 @@ describe('createWebRuntimeSessionTerminal', () => {
}
)
it.each([
{ gated: false, authority: false },
{ gated: true, authority: true }
])(
'routes a Kimi resume by capability (advertised=$gated) instead of the generic host-authority probe',
async ({ gated, authority }) => {
// Why: an old host answers the widened ensureAgentSession enum with invalid_argument, which
// runRemoteAgentSessionLaunch does not retry on — the per-agent probe is the only thing
// keeping a remote Kimi resume from dying instead of degrading to a legacy launch.
const runtimeCall = vi.fn(async (request: { method: string }) => {
if (request.method === 'status.get') {
return {
id: 'status',
ok: true,
result: {
runtimeId: 'runtime-1',
graphStatus: 'ready',
runtimeProtocolVersion: 3,
minCompatibleRuntimeClientVersion: 2,
capabilities: [
'agent-session.host-authority.v1',
...(gated ? ['agent-session.kimi-resume.v1'] : [])
]
}
}
}
if (request.method === 'terminal.ensureAgentSession') {
return {
id: 'ensure',
ok: true,
result: {
terminal: {
handle: 'term-kimi',
worktreeId: WORKTREE_ID,
tabId: 'host-tab-kimi',
paneKey: `host-tab-kimi:${FOCUS_LEAF_ID}`
},
disposition: 'created'
}
}
}
if (request.method === 'session.tabs.createTerminal') {
return {
id: 'legacy-create',
ok: true,
result: { tab: { id: 'host-tab-kimi', leafId: FOCUS_LEAF_ID } }
}
}
return { id: 'list', ok: true, result: makeSnapshot() }
})
vi.stubGlobal('window', {
api: { runtimeEnvironments: { call: runtimeCall } }
})
await expect(
createWebRuntimeSessionTerminal({
worktreeId: WORKTREE_ID,
agentSessionKind: 'resume',
launchAgent: 'kimi',
command: "kimi '--session' 'session_431324d7'",
providerSession: { key: 'session_id', id: 'session_431324d7' }
})
).resolves.toEqual({ status: 'created' })
const methods = runtimeCall.mock.calls.map(([request]) => request.method)
expect(methods.includes('terminal.ensureAgentSession')).toBe(authority)
expect(methods.includes('session.tabs.createTerminal')).toBe(!authority)
}
)
it('creates paired web agents through host authority so activation is mirrored', async () => {
const snapshot = {
...makeSnapshot(),
@@ -19,10 +19,8 @@ import type {
SleepingAgentLaunchConfig,
AgentProviderSessionMetadata
} from '../../../shared/agent-session-resume'
import {
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import { BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { agentResumeHostAuthorityCapability } from './agent-resume-host-authority-capability'
import type {
AgentLaunchPreferences,
AgentPromptDelivery,
@@ -337,13 +335,15 @@ async function createWebRuntimeSessionTerminalResult(
})) as RuntimeRpcResponse<RuntimeCreateAgentSessionResult>
)
)
const resumeHostAuthorityCapability =
args.agentSessionKind === 'resume' ? agentResumeHostAuthorityCapability(agent) : undefined
const created = await runRemoteAgentSessionLaunch<{
terminal: CreatedAgentTerminalIdentity
}>({
environmentId,
...(hostAuthority ? { hostAuthority } : {}),
...(args.agentSessionKind === 'resume' && agent === 'omp'
? { hostAuthorityCapability: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY }
...(resumeHostAuthorityCapability
? { hostAuthorityCapability: resumeHostAuthorityCapability }
: {}),
legacy: async () => {
const response = await callEnvironment({
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import type { AppState } from '../types'
import { createTestStore, makeTab } from './store-test-helpers'
describe('quit-time capture for newly resumable agents', () => {
it('checkpoints a live Kimi provider session before quit-time capture', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'finish the task',
agentType: 'kimi'
},
'Kimi',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{
providerSession: {
key: 'session_id',
id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201'
}
}
)
// Why: this is the #15155 regression — without kimi in RESUMABLE_TUI_AGENTS no sleeping
// record is ever captured, so restart drops the session instead of resuming it.
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
agent: 'kimi',
worktreeId: 'wt-1',
tabId: 'tab-1',
providerSession: { key: 'session_id', id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201' },
origin: 'live'
})
})
})
+16 -2
View File
@@ -24,6 +24,10 @@ describe('agent session resume metadata', () => {
expect(isResumableTuiAgent('copilot')).toBe(true)
})
it('treats Kimi Code as a resumable TUI agent', () => {
expect(isResumableTuiAgent('kimi')).toBe(true)
})
it.each([
['claude', { session_id: 'claude-session' }, { key: 'session_id', id: 'claude-session' }],
['codex', { session_id: 'codex-session' }, { key: 'session_id', id: 'codex-session' }],
@@ -54,7 +58,12 @@ describe('agent session resume metadata', () => {
{ session_id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' },
{ key: 'session_id', id: '940237d9-c712-48e8-bca1-fd75fc4a8d4b' }
],
['copilot', { sessionId: 'copilot-camel' }, { key: 'session_id', id: 'copilot-camel' }]
['copilot', { sessionId: 'copilot-camel' }, { key: 'session_id', id: 'copilot-camel' }],
[
'kimi',
{ session_id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201' },
{ key: 'session_id', id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201' }
]
] as const)('extracts %s provider session ids', (source, payload, expected) => {
expect(extractAgentProviderSession(source, payload)).toEqual(expected)
})
@@ -80,7 +89,12 @@ describe('agent session resume metadata', () => {
{ key: 'session_id', id: 's1', transcriptPath: '/tmp/prime-session.jsonl' },
['prime-agent', '--resume', '/tmp/prime-session.jsonl']
],
['copilot', { key: 'session_id', id: 's1' }, ['copilot', '--resume=s1']]
['copilot', { key: 'session_id', id: 's1' }, ['copilot', '--resume=s1']],
[
'kimi',
{ key: 'session_id', id: 'session_431324d7' },
['kimi', '--session', 'session_431324d7']
]
] as const)('builds %s resume argv', (agent, providerSession, expected) => {
expect(getAgentResumeArgv(agent, providerSession)).toEqual(expected)
})
+5 -1
View File
@@ -15,7 +15,8 @@ export const RESUMABLE_TUI_AGENTS = [
'devin',
'omp',
'prime-agent',
'copilot'
'copilot',
'kimi'
] as const satisfies readonly TuiAgent[]
export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number]
@@ -287,5 +288,8 @@ export function getAgentResumeArgv(
// resume commands, so local and remote resumes agree on one spelling.
case 'copilot':
return providerSession.key === 'session_id' ? ['copilot', `--resume=${id}`] : null
// Why: Kimi resumes by id with --session; sessions are work-dir-scoped (enforced by callers).
case 'kimi':
return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null
}
}
+49 -1
View File
@@ -4,12 +4,59 @@ export type PosixCommandPathLookupTarget =
const SHELL_VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
export function buildPosixCommandPathLookupScript(target: PosixCommandPathLookupTarget): string {
export type PosixCommandPathLookupOptions = {
/**
* Skip PATH components that are Windows drives mounted into the guest.
*
* WSL appends the Windows PATH to the guest PATH, so a Windows `claude` can
* sit ahead of a real guest install. Rejecting the result AFTER the walk is
* not equivalent: the walk stops at the first hit, so discarding it reports
* "not installed" for a user who has both. Skipping the component keeps the
* walk going and finds the guest binary behind it.
*
* Matched by mount metadata, not by a `/mnt` name: the automount root is
* configurable, and `/mnt` is an ordinary directory name on a Linux box.
*/
skipWindowsMountDirs?: boolean
}
export function buildPosixCommandPathLookupScript(
target: PosixCommandPathLookupTarget,
options: PosixCommandPathLookupOptions = {}
): string {
const commandAssignment = buildCommandAssignment(target)
// `drvfs` is what WSL mounts a Windows drive as, wherever the automount root
// is; 9p/virtiofs cover the WSL2 shapes.
//
// `${x+set}` so the table is read once per SHELL, not once per lookup: the
// caller embeds this script inside `for cmd in <every agent>`, so an
// unconditional assignment forked awk once per probed CLI -- 36 of them
// against a 10s budget. The variable outlives the iteration, so the second
// pass finds it set. Deliberately not `[ -n ... ]`: a host with no Windows
// mounts yields the empty string, which must still count as read.
const mountPrelude = options.skipWindowsMountDirs
? [
'[ "${_orca_win_mounts+set}" = set ] || _orca_win_mounts=$(awk \'$3 == "drvfs" || $3 == "9p" || $3 == "virtiofs" { print $2 }\' /proc/mounts 2>/dev/null)'
]
: []
const skipMountComponent = options.skipWindowsMountDirs
? [
' for _orca_win_mount in $_orca_win_mounts; do',
' case "$_orca_lookup_component/" in',
' "$_orca_win_mount"/*) _orca_lookup_component= ;;',
' esac',
' done',
' if [ -z "$_orca_lookup_component" ]; then',
' [ -n "$_orca_lookup_has_more" ] || break',
' continue',
' fi'
]
: []
// Shell command resolution can be masked by aliases, functions, and builtins, so inspect PATH.
return [
`_orca_lookup_command=${commandAssignment}`,
'resolved=',
...mountPrelude,
'case "$_orca_lookup_command" in',
' */*)',
' case "$_orca_lookup_command" in',
@@ -34,6 +81,7 @@ export function buildPosixCommandPathLookupScript(target: PosixCommandPathLookup
' _orca_lookup_has_more=',
' ;;',
' esac',
...skipMountComponent,
' [ -n "$_orca_lookup_component" ] || _orca_lookup_component=.',
' case "$_orca_lookup_component" in',
' /*) _orca_lookup_candidate=$_orca_lookup_component/$_orca_lookup_command ;;',
+5
View File
@@ -101,6 +101,10 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY =
'agent-session.host-authority.v1' as const
export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY =
'agent-session.omp-resume-path.v1' as const
// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
// older host answers the unknown member with invalid_argument — a code the launch fallback does
// not retry on — so clients must probe before taking the host-authority path.
export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-resume.v1' as const
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
@@ -144,6 +148,7 @@ export const RUNTIME_CAPABILITIES = [
REMOTE_SERVER_UPDATE_CAPABILITY,
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY,
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { windowsLongPathGitArgs } from './windows-long-path-git-args'
describe('windowsLongPathGitArgs', () => {
it('enables long paths for a Windows drive path', () => {
expect(windowsLongPathGitArgs('C:\\Users\\dev\\repo', 'win32')).toEqual([
'-c',
'core.longpaths=true'
])
})
it.each(['darwin', 'linux'] as const)('returns nothing on %s', (platform) => {
expect(windowsLongPathGitArgs('/home/dev/repo', platform)).toEqual([])
})
it.each(['\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', '\\\\wsl$\\Ubuntu\\home\\dev\\repo'])(
'returns nothing for the WSL UNC path %s',
(cwd) => {
expect(windowsLongPathGitArgs(cwd, 'win32')).toEqual([])
}
)
it('never mutates the shared constant', () => {
const first = windowsLongPathGitArgs('C:\\repo', 'win32')
first.push('--bogus')
expect(windowsLongPathGitArgs('C:\\repo', 'win32')).toEqual(['-c', 'core.longpaths=true'])
})
})
+25
View File
@@ -0,0 +1,25 @@
import { parseWslUncPath } from './wsl-paths'
const WINDOWS_LONG_PATH_GIT_ARGS = ['-c', 'core.longpaths=true'] as const
/**
* Global `git -c` options that let a Windows checkout exceed MAX_PATH.
*
* Why command scope: Git for Windows aborts deep checkouts with "Filename too
* long" unless core.longpaths is on, and `-c` applies it to this invocation
* only — never `--global`, `--system`, or `--local`, so no user config is
* written. Available since Git 1.9, well under the 2.25 baseline.
*
* Why keyed off cwd rather than a wslDistro option: a `C:\...` cwd can still be
* served by host git.exe even when a distro is configured, and Linux git parses
* and ignores the key, so only a true `\\wsl.localhost\...` path opts out.
*/
export function windowsLongPathGitArgs(
cwd: string,
platform: NodeJS.Platform = process.platform
): string[] {
if (platform !== 'win32' || parseWslUncPath(cwd)) {
return []
}
return [...WINDOWS_LONG_PATH_GIT_ARGS]
}
@@ -41,6 +41,42 @@ describe('parseWorkspaceSession sleeping agents', () => {
}
})
it('hydrates a persisted Kimi sleeping agent record', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab1:pane-1': {
paneKey: 'tab1:pane-1',
tabId: 'tab1',
worktreeId: 'wt',
agent: 'kimi',
providerSession: {
key: 'session_id',
id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201'
},
prompt: 'continue',
state: 'working',
capturedAt: 10,
updatedAt: 9,
terminalTitle: 'Kimi',
origin: 'live'
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
// Why: the record must survive the resumable-agent refine, or restore silently drops it.
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']).toMatchObject({
agent: 'kimi',
providerSession: { key: 'session_id', id: 'session_431324d7-2165-42f0-9ecd-9f93437b3201' }
})
}
})
it('preserves the authoritative Pi session file through hydration', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
@@ -5,6 +5,7 @@ import {
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override'
import {
cleanupCompletedWorkerFixture,
clearCompletedWorkerLedger,
@@ -46,13 +47,20 @@ for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) {
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
await waitForActivePanePtyId(orcaPage)
await orcaPage.evaluate(async (agentCommand) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand },
disabledTuiAgents: [],
terminalHiddenViewParking: false
})
}, completedWorkerFakeCodexCommand)
await orcaPage.evaluate(
async ({ agentCommand, terminalWindowsShell }) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand },
terminalWindowsShell,
disabledTuiAgents: [],
terminalHiddenViewParking: false
})
},
{
agentCommand: completedWorkerFakeCodexCommand,
terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL
}
)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const isolatedHome = await electronApp.evaluate(({ app }) => app.getPath('home'))
@@ -183,6 +191,13 @@ for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) {
return dispatchCapability
})
.not.toBeNull()
await expect
.poll(() =>
readCompletedWorkerLedger()
.filter((event) => event.event === 'ack')
.map((event) => event.mode)
)
.toEqual(['bracketed'])
if (!dispatchCapability) {
throw new Error('Background worker did not receive its dispatch capability')
}
@@ -194,7 +209,15 @@ for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) {
)
await orcaPage.evaluate(
({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => {
({
agentCommand,
paneKey,
providerSessionId,
tabId,
terminalHandle,
transcriptPath,
worktreeId
}) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('Renderer store unavailable')
@@ -208,7 +231,10 @@ for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) {
const recovery = {
providerSession,
launchConfig: {
agentCommand: 'codex',
// Why not bare 'codex': resume prefers the captured command over
// agentCmdOverrides, so a bare name would resolve the machine's real
// Codex off PATH and unpin the adoption leg this spec exercises.
agentCommand,
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
}
@@ -231,6 +257,7 @@ for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) {
)
},
{
agentCommand: completedWorkerFakeCodexCommand,
paneKey: workerPaneKey,
providerSessionId: PROVIDER_SESSION_ID,
tabId: worker.tabId,
@@ -36,13 +36,11 @@ if (args.includes('app-server')) {
append({ event: 'spawn', args })
process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n')
${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
let pasteEnded = false
process.stdin.on('data', (chunk) => {
const input = chunk.toString()
const pasteEndScan = scanFakeAgentPasteEnd(fakeAgentPasteEndTail, input)
fakeAgentPasteEndTail = pasteEndScan.tail
if (pasteEndScan.ended) {
pasteEnded = true
if (pasteEndScan.pasteEndOffset !== null) {
process.stdout.write('\\x1b[?25h')
}
append({ event: 'input', input })
@@ -50,10 +48,12 @@ process.stdin.on('data', (chunk) => {
append({ event: 'normal-exit' })
process.exit(0)
}
if (pasteEnded && input.includes('\\r')) {
process.stdout.write('\\u001b]0;Codex Working\\u0007ACK\\n')
fakeAgentMaybeAck(pasteEndScan, input, (mode) => {
append({ event: 'ack', mode })
const message = mode === 'bracketed' ? 'ACK' : 'PASTE_PROTOCOL_ERROR'
process.stdout.write('\\u001b]0;Codex Working\\u0007' + message + '\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
}
})
})
process.stdin.setRawMode?.(true)
process.stdin.resume()
@@ -79,9 +79,10 @@ export const completedWorkerLaunchEnv = {
export type LifecycleEvent = {
pid: number
event: 'spawn' | 'input' | 'normal-exit'
event: 'spawn' | 'input' | 'ack' | 'normal-exit'
args?: string[]
input?: string
mode?: 'bracketed' | 'unbracketed'
}
export type TerminalIdentity = Pick<
@@ -1,10 +1,21 @@
import { quoteStartupArg } from '../../../src/shared/tui-agent-startup-shell'
import { quoteStartupArg, resolveStartupShell } from '../../../src/shared/tui-agent-startup-shell'
import { resolveLocalWindowsAgentStartupShell } from '../../../src/shared/windows-terminal-shell'
/** Specs must apply this through `updateSettings`; the override below is quoted for it. */
export const FAKE_AGENT_WINDOWS_SHELL = 'powershell.exe'
export function buildFakeAgentCommandOverride(
executablePath: string,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
terminalWindowsShell: string = FAKE_AGENT_WINDOWS_SHELL
): string {
const shell = platform === 'win32' ? 'powershell' : 'posix'
// Why resolve rather than assume PowerShell on win32: the startup shell comes
// from the user's terminalWindowsShell setting, and a cmd/Git Bash/WSL host
// would silently fail to launch a PowerShell-quoted path.
const shell = resolveStartupShell(
platform,
resolveLocalWindowsAgentStartupShell({ platform, isRemote: false, terminalWindowsShell })
)
const quotedPath = quoteStartupArg(executablePath, shell)
return platform === 'win32' ? `& ${quotedPath}` : quotedPath
return shell === 'powershell' ? `& ${quotedPath}` : quotedPath
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { WINDOWS_GIT_BASH_SHELL } from '../../../src/shared/windows-terminal-shell'
import { buildFakeAgentCommandOverride } from './fake-agent-command-override'
describe('buildFakeAgentCommandOverride', () => {
@@ -13,4 +14,19 @@ describe('buildFakeAgentCommandOverride', () => {
"'/tmp/fake agent'\"'\"'s/codex'"
)
})
it('drops the PowerShell call operator when the Windows host runs cmd', () => {
expect(
buildFakeAgentCommandOverride('C:\\Temp\\fake agent\\codex.cmd', 'win32', 'cmd.exe')
).toBe('"C:\\Temp\\fake agent\\codex.cmd"')
})
it('uses POSIX quoting when the Windows host runs Git Bash or WSL', () => {
expect(buildFakeAgentCommandOverride('/tmp/fake agent/codex', 'win32', 'wsl.exe')).toBe(
"'/tmp/fake agent/codex'"
)
expect(
buildFakeAgentCommandOverride("/tmp/fake agent's/codex", 'win32', WINDOWS_GIT_BASH_SHELL)
).toBe("'/tmp/fake agent'\"'\"'s/codex'")
})
})
@@ -1,15 +1,76 @@
export type FakeAgentPasteEndScan = { tail: string; ended: boolean }
export type FakeAgentPasteEndScan = {
tail: string
pasteBeginOffset: number | null
pasteEndOffset: number | null
}
export function scanFakeAgentPasteEnd(tail: string, input: string): FakeAgentPasteEndScan {
const marker = '\x1b[201~'
const beginMarker = '\x1b[200~'
const endMarker = '\x1b[201~'
const candidate = tail + input
const beginIndex = candidate.indexOf(beginMarker)
const endIndex = candidate.indexOf(endMarker)
return {
tail: candidate.slice(1 - marker.length),
ended: candidate.includes(marker)
tail: candidate.slice(1 - endMarker.length),
pasteBeginOffset: beginIndex === -1 ? null : beginIndex + beginMarker.length - tail.length,
pasteEndOffset: endIndex === -1 ? null : endIndex + endMarker.length - tail.length
}
}
/** Fallback ACK delay so an unbracketed delivery path fails an assertion instead of timing out the suite. */
export const FAKE_AGENT_UNBRACKETED_ACK_GRACE_MS = 2_000
export const FAKE_AGENT_PASTE_END_SCANNER_SOURCE = `
const scanFakeAgentPasteEnd = ${scanFakeAgentPasteEnd.toString()}
let fakeAgentPasteEndTail = ''
let fakeAgentInsidePaste = false
let fakeAgentSawPasteEnd = false
let fakeAgentSawSubmit = false
let fakeAgentUnbracketedAckTimer = null
function fakeAgentMaybeAck(scan, input, ack) {
const events = []
if (scan.pasteBeginOffset !== null) {
events.push({ offset: scan.pasteBeginOffset, type: 'begin' })
}
if (scan.pasteEndOffset !== null) {
events.push({ offset: scan.pasteEndOffset, type: 'end' })
}
for (let offset = input.indexOf('\\r'); offset >= 0; offset = input.indexOf('\\r', offset + 1)) {
events.push({ offset, type: 'submit' })
}
events.sort((left, right) => left.offset - right.offset || (left.type === 'submit' ? 1 : -1))
for (const event of events) {
if (fakeAgentSawSubmit) break
if (event.type === 'begin') {
fakeAgentInsidePaste = true
} else if (event.type === 'end') {
if (fakeAgentInsidePaste) {
fakeAgentInsidePaste = false
fakeAgentSawPasteEnd = true
}
} else if (!fakeAgentInsidePaste) {
fakeAgentSawSubmit = true
}
}
if (!fakeAgentSawSubmit) return
if (fakeAgentSawPasteEnd) {
if (fakeAgentUnbracketedAckTimer) {
clearTimeout(fakeAgentUnbracketedAckTimer)
fakeAgentUnbracketedAckTimer = null
}
fakeAgentInsidePaste = false
fakeAgentSawPasteEnd = false
fakeAgentSawSubmit = false
ack('bracketed')
return
}
if (fakeAgentUnbracketedAckTimer) return
fakeAgentUnbracketedAckTimer = setTimeout(() => {
fakeAgentUnbracketedAckTimer = null
fakeAgentInsidePaste = false
fakeAgentSawPasteEnd = false
fakeAgentSawSubmit = false
ack('unbracketed')
}, ${FAKE_AGENT_UNBRACKETED_ACK_GRACE_MS})
}
`
@@ -14,26 +14,104 @@ describe('scanFakeAgentPasteEnd', () => {
const first = scanFakeAgentPasteEnd('', PASTE_END.slice(0, splitAt))
const second = scanFakeAgentPasteEnd(first.tail, PASTE_END.slice(splitAt))
expect(first.ended).toBe(false)
expect(second.ended).toBe(true)
expect(first.pasteBeginOffset).toBeNull()
expect(first.pasteEndOffset).toBeNull()
expect(second.pasteBeginOffset).toBeNull()
expect(second.pasteEndOffset).toBe(PASTE_END.length - splitAt)
}
)
it('keeps only the possible marker prefix between chunks', () => {
const scan = scanFakeAgentPasteEnd('', `worker prompt${PASTE_END.slice(0, -1)}`)
expect(scan).toEqual({ tail: PASTE_END.slice(0, -1), ended: false })
expect(scan).toEqual({
tail: PASTE_END.slice(0, -1),
pasteBeginOffset: null,
pasteEndOffset: null
})
})
it('emits standalone JavaScript for the fake agent process', () => {
const context = vm.createContext({ result: null })
vm.runInContext(
`${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
const first = scanFakeAgentPasteEnd('', '\\x1b[20')
const first = scanFakeAgentPasteEnd('', '\x1b[20')
result = scanFakeAgentPasteEnd(first.tail, '1~')`,
context
)
expect(context.result).toEqual({ tail: PASTE_END.slice(1), ended: true })
expect(context.result).toEqual({
tail: PASTE_END.slice(1),
pasteBeginOffset: null,
pasteEndOffset: 2
})
})
it('recognizes a paste frame and submit delivered in separate chunks', () => {
const context = vm.createContext({ result: null })
vm.runInContext(
`${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
const modes = []
fakeAgentMaybeAck({ pasteBeginOffset: 1, pasteEndOffset: null }, '', (mode) => modes.push(mode))
fakeAgentMaybeAck({ pasteBeginOffset: null, pasteEndOffset: 1 }, '', (mode) => modes.push(mode))
fakeAgentMaybeAck({ pasteBeginOffset: null, pasteEndOffset: null }, '\\r', (mode) => modes.push(mode))
result = modes`,
context
)
expect(context.result).toEqual(['bracketed'])
})
it('does not upgrade a paste frame received after submit', () => {
const timers: (() => void)[] = []
const context = vm.createContext({
result: null,
setTimeout: (fn: () => void) => timers.push(fn)
})
vm.runInContext(
`${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
const modes = []
fakeAgentMaybeAck({ pasteBeginOffset: null, pasteEndOffset: null }, '\\r', (mode) => modes.push(mode))
fakeAgentMaybeAck({ pasteBeginOffset: 1, pasteEndOffset: null }, '', (mode) => modes.push(mode))
fakeAgentMaybeAck({ pasteBeginOffset: null, pasteEndOffset: 1 }, '', (mode) => modes.push(mode))
result = modes`,
context
)
expect(context.result).toEqual([])
timers.forEach((timer) => timer())
expect(vm.runInContext('modes', context)).toEqual(['unbracketed'])
})
it('ignores multiline carriage returns inside one bracketed paste chunk', () => {
const context = vm.createContext({ result: null })
vm.runInContext(
`${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
const modes = []
const input = '\\x1b[200~line one\\rline two\\x1b[201~\\r'
const scan = scanFakeAgentPasteEnd('', input)
fakeAgentMaybeAck(scan, input, (mode) => modes.push(mode))
result = modes`,
context
)
expect(context.result).toEqual(['bracketed'])
})
it('ignores multiline carriage returns across bracketed paste chunks', () => {
const context = vm.createContext({ result: null })
vm.runInContext(
`${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
const modes = []
for (const input of ['\\x1b[200~line one', '\\rline two', '\\x1b[201~', '\\r']) {
const scan = scanFakeAgentPasteEnd(fakeAgentPasteEndTail, input)
fakeAgentPasteEndTail = scan.tail
fakeAgentMaybeAck(scan, input, (mode) => modes.push(mode))
}
result = modes`,
context
)
expect(context.result).toEqual(['bracketed'])
})
})
@@ -19,7 +19,10 @@ import Database from '../../src/main/sqlite/sync-database'
import { LEGACY_CONTRACT_VERSION } from '../../src/main/runtime/orchestration/db'
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override'
import {
buildFakeAgentCommandOverride,
FAKE_AGENT_WINDOWS_SHELL
} from './helpers/fake-agent-command-override'
import { FAKE_AGENT_PASTE_END_SCANNER_SOURCE } from './helpers/fake-agent-paste-end-scanner'
const PROVIDER_SESSION_ID = 'e2e-missing-legacy-worker'
@@ -46,22 +49,23 @@ appendLedger('ORCA_E2E_SPAWN_LEDGER', { event: 'spawn' })
process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n')
let acknowledged = false
${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
let pasteEnded = false
process.stdin.on('data', (chunk) => {
const input = chunk.toString()
const pasteEndScan = scanFakeAgentPasteEnd(fakeAgentPasteEndTail, input)
fakeAgentPasteEndTail = pasteEndScan.tail
if (pasteEndScan.ended) {
pasteEnded = true
if (pasteEndScan.pasteEndOffset !== null) {
process.stdout.write('\\x1b[?25h')
}
if (input.includes('\\x03')) {
appendLedger('ORCA_E2E_INTERRUPTION_LEDGER', { event: 'stdin-ctrl-c' })
}
if (!acknowledged && pasteEnded && input.includes('\\r')) {
acknowledged = true
process.stdout.write('\\u001b]0;Codex Working\\u0007ACK\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
if (!acknowledged) {
fakeAgentMaybeAck(pasteEndScan, input, (mode) => {
acknowledged = true
const message = mode === 'bracketed' ? 'ACK' : 'PASTE_PROTOCOL_ERROR'
process.stdout.write('\\u001b]0;Codex Working\\u0007' + message + '\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
})
}
})
process.stdin.setRawMode?.(true)
@@ -215,11 +219,15 @@ test('a missing legacy worker cannot spawn a replacement during restart recovery
firstApp = first.app
const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath)
await waitForSessionReady(first.page)
await first.page.evaluate(async (agentCommand) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand }
})
}, fakeCodexCommand)
await first.page.evaluate(
async ({ agentCommand, terminalWindowsShell }) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand },
terminalWindowsShell
})
},
{ agentCommand: fakeCodexCommand, terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL }
)
await ensureTerminalVisible(first.page)
await getActiveTabId(first.page)
await waitForActivePanePtyId(first.page)
@@ -285,7 +293,7 @@ test('a missing legacy worker cannot spawn a replacement during restart recovery
const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath)
await first.page.evaluate(
({ paneKey, tabId, workerWorktreeId, terminalHandle, transcript }) => {
({ agentCommand, paneKey, tabId, workerWorktreeId, terminalHandle, transcript }) => {
window.__store?.getState().setAgentStatus(
paneKey,
{ state: 'working', prompt: 'Respond ACK and remain idle', agentType: 'codex' },
@@ -299,7 +307,10 @@ test('a missing legacy worker cannot spawn a replacement during restart recovery
transcriptPath: transcript
},
launchConfig: {
agentCommand: 'codex',
// Why not bare 'codex': resume prefers the captured command over
// agentCmdOverrides, so a bare name would resolve the machine's real
// Codex off PATH and unpin the adoption leg this spec exercises.
agentCommand,
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
}
@@ -308,6 +319,7 @@ test('a missing legacy worker cannot spawn a replacement during restart recovery
window.__store?.getState().captureAllSleepingAgentSessions('quit')
},
{
agentCommand: fakeCodexCommand,
paneKey: workerPaneKey,
tabId: worker!.tabId,
workerWorktreeId: worker!.worktreeId,
@@ -24,7 +24,10 @@ import {
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { listAllOrchestrationRuns } from './orchestration-run-pages'
import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override'
import {
buildFakeAgentCommandOverride,
FAKE_AGENT_WINDOWS_SHELL
} from './helpers/fake-agent-command-override'
import { FAKE_AGENT_PASTE_END_SCANNER_SOURCE } from './helpers/fake-agent-paste-end-scanner'
const PROVIDER_SESSION_ID = 'e2e-legacy-orchestration-worker'
@@ -46,7 +49,7 @@ function appendLedger(envName, event) {
appendFileSync(ledgerPath, JSON.stringify({ pid: process.pid, at: Date.now(), ...event }) + '\\n')
} catch {}
}
async function emitAuthorityHook() {
async function emitAuthorityHook(hookEventName) {
const port = process.env.ORCA_AGENT_HOOK_PORT
const token = process.env.ORCA_AGENT_HOOK_TOKEN
const launchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN
@@ -66,12 +69,16 @@ async function emitAuthorityHook() {
version: process.env.ORCA_AGENT_HOOK_VERSION,
launchToken,
payload: {
hook_event_name: 'UserPromptSubmit',
hook_event_name: hookEventName,
prompt: 'Respond ACK and remain idle'
}
})
})
appendLedger('ORCA_E2E_AUTHORITY_LEDGER', { event: 'authority-hook', status: response.status })
appendLedger('ORCA_E2E_AUTHORITY_LEDGER', {
event: 'authority-hook',
hookEventName,
status: response.status
})
} catch (error) {
appendLedger('ORCA_E2E_AUTHORITY_LEDGER', {
event: 'authority-hook-error',
@@ -85,26 +92,28 @@ if (process.argv.slice(2).includes('app-server')) {
}
appendLedger('ORCA_E2E_SPAWN_LEDGER', { event: 'spawn', argv: process.argv.slice(2) })
process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n')
void emitAuthorityHook()
const sessionStartHook = emitAuthorityHook('SessionStart')
let acknowledged = false
let lifecycleSent = false
${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
let pasteEnded = false
process.stdin.on('data', (chunk) => {
const input = chunk.toString()
const pasteEndScan = scanFakeAgentPasteEnd(fakeAgentPasteEndTail, input)
fakeAgentPasteEndTail = pasteEndScan.tail
if (pasteEndScan.ended) {
pasteEnded = true
if (pasteEndScan.pasteEndOffset !== null) {
process.stdout.write('\\x1b[?25h')
}
if (input.includes('\\x03')) {
appendLedger('ORCA_E2E_INTERRUPTION_LEDGER', { event: 'stdin-ctrl-c' })
}
if (!acknowledged && pasteEnded && input.includes('\\r')) {
acknowledged = true
process.stdout.write('\\u001b]0;Codex Working\\u0007ACK\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
if (!acknowledged) {
fakeAgentMaybeAck(pasteEndScan, input, (mode) => {
acknowledged = true
void sessionStartHook.then(() => emitAuthorityHook('UserPromptSubmit'))
const message = mode === 'bracketed' ? 'ACK' : 'PASTE_PROTOCOL_ERROR'
process.stdout.write('\\u001b]0;Codex Working\\u0007' + message + '\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
})
}
const legacyCompletion = input.match(/ORCA_E2E_RUN_LEGACY_DONE:([A-Za-z0-9+/=]+)/)
if (!lifecycleSent && legacyCompletion) {
@@ -178,6 +187,7 @@ type LedgerEvent = {
stdout?: string
stderr?: string
error?: string
hookEventName?: string
}
type PersistedWorkspaceSession = {
@@ -429,11 +439,15 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION
firstApp = first.app
const worktreeId = await attachRepoAndOpenTerminal(first.page, repoPath)
await waitForSessionReady(first.page)
await first.page.evaluate(async (agentCommand) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand }
})
}, fakeCodexCommand)
await first.page.evaluate(
async ({ agentCommand, terminalWindowsShell }) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand },
terminalWindowsShell
})
},
{ agentCommand: fakeCodexCommand, terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL }
)
await ensureTerminalVisible(first.page)
const coordinatorTabId = await getActiveTabId(first.page)
expect(coordinatorTabId).toBeTruthy()
@@ -531,11 +545,29 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION
expect(readLedger(interruptionLedgerPath)).toEqual([])
await expect
.poll(() => readLedger(authorityLedgerPath))
.toEqual([expect.objectContaining({ event: 'authority-hook', status: 204 })])
.toEqual([
expect.objectContaining({
event: 'authority-hook',
hookEventName: 'SessionStart',
status: 204
}),
expect.objectContaining({
event: 'authority-hook',
hookEventName: 'UserPromptSubmit',
status: 204
})
])
const transcriptPath = session.seedCodexResumeRollout(PROVIDER_SESSION_ID, repoPath)
await first.page.evaluate(
({ paneKey, tabId, worktreeId: workerWorktreeId, terminalHandle, transcript }) => {
({
agentCommand,
paneKey,
tabId,
worktreeId: workerWorktreeId,
terminalHandle,
transcript
}) => {
window.__store?.getState().setAgentStatus(
paneKey,
{ state: 'working', prompt: 'Respond ACK and remain idle', agentType: 'codex' },
@@ -549,7 +581,10 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION
transcriptPath: transcript
},
launchConfig: {
agentCommand: 'codex',
// Why not bare 'codex': resume prefers the captured command over
// agentCmdOverrides, so a bare name would resolve the machine's real
// Codex off PATH and unpin the adoption leg this spec exercises.
agentCommand,
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: {}
}
@@ -558,6 +593,7 @@ for (const contractVersion of [LEGACY_CONTRACT_VERSION, CURRENT_CONTRACT_VERSION
window.__store?.getState().captureAllSleepingAgentSessions('quit')
},
{
agentCommand: fakeCodexCommand,
paneKey: workerPaneKey,
tabId: worker!.tabId,
worktreeId: worker!.worktreeId,
@@ -8,7 +8,10 @@ import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } fro
import { RuntimeClient } from '../../src/cli/runtime-client'
import Database from '../../src/main/sqlite/sync-database'
import type { RuntimeTerminalListResult, RuntimeTerminalRead } from '../../src/shared/runtime-types'
import { buildFakeAgentCommandOverride } from './helpers/fake-agent-command-override'
import {
buildFakeAgentCommandOverride,
FAKE_AGENT_WINDOWS_SHELL
} from './helpers/fake-agent-command-override'
import { FAKE_AGENT_PASTE_END_SCANNER_SOURCE } from './helpers/fake-agent-paste-end-scanner'
const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-settlement-release-'))
@@ -27,21 +30,22 @@ if (process.argv.slice(2).includes('app-server')) {
let capability = null
let acknowledged = false
${FAKE_AGENT_PASTE_END_SCANNER_SOURCE}
let pasteEnded = false
process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n')
process.stdin.on('data', (chunk) => {
const input = chunk.toString()
const pasteEndScan = scanFakeAgentPasteEnd(fakeAgentPasteEndTail, input)
fakeAgentPasteEndTail = pasteEndScan.tail
if (pasteEndScan.ended) {
pasteEnded = true
if (pasteEndScan.pasteEndOffset !== null) {
process.stdout.write('\\x1b[?25h')
}
capability ||= input.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] || null
if (!acknowledged && pasteEnded && input.includes('\\r')) {
acknowledged = true
process.stdout.write('\\u001b]0;Codex Working\\u0007ACK\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
if (!acknowledged) {
fakeAgentMaybeAck(pasteEndScan, input, (mode) => {
acknowledged = true
const message = mode === 'bracketed' ? 'ACK' : 'PASTE_PROTOCOL_ERROR'
process.stdout.write('\\u001b]0;Codex Working\\u0007' + message + '\\n')
setTimeout(() => process.stdout.write('\\u001b]0;Codex Ready\\u0007'), 10)
})
}
const encoded = input.match(/ORCA_E2E_WORKER_DONE:([A-Za-z0-9+/=]+)/)?.[1]
if (!encoded || !capability) return
@@ -140,11 +144,15 @@ test('compiled CLI rejects false completion then reconciles the dead retained wo
test.setTimeout(180_000)
rmSync(cliLedgerPath, { force: true })
await waitForSessionReady(orcaPage)
await orcaPage.evaluate(async (agentCommand) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand }
})
}, fakeCodexCommand)
await orcaPage.evaluate(
async ({ agentCommand, terminalWindowsShell }) => {
await window.__store?.getState().updateSettings({
agentCmdOverrides: { codex: agentCommand },
terminalWindowsShell
})
},
{ agentCommand: fakeCodexCommand, terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL }
)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActivePanePtyId(orcaPage)
@@ -0,0 +1,83 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
// Why: post-fix trailing overhang is ~0px; 16px is subpixel headroom that still
// fails the old pr-[6.5rem] (~104px) reservation.
const MAX_TRAILING_CHROME_PX = 16
const VIEWPORTS = [
{ name: 'desktop', size: { width: 1440, height: 900 } },
{ name: 'mobile', size: { width: 390, height: 844 } }
] as const
async function enableExperimentalPet(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
// Why: updateSettings swallows errors; throw so a failed persist fails loudly.
await store.getState().updateSettingsOrThrow({ experimentalPet: true })
})
await expect
.poll(
async () =>
page.evaluate(() => window.__store?.getState().settings?.experimentalPet === true),
{ message: 'experimentalPet did not become true after updateSettingsOrThrow' }
)
.toBe(true)
}
async function assertPetTriggerFitsLabel(page: Page): Promise<void> {
const trigger = page.getByRole('button', { name: 'Pet menu' })
// Why: only the label span is a direct child; avoid matching nested menu spans
// if the open state ever leaks into the measurement.
const label = trigger.locator(':scope > span')
await expect(trigger).toBeVisible()
await expect(label).toBeVisible()
// Why: measure trailing overhang (trigger right − label right), not total width
// delta — the regression is empty space *after* the label (the old pr-[6.5rem]).
await expect
.poll(
async () => {
const [triggerBox, labelBox] = await Promise.all([
trigger.boundingBox(),
label.boundingBox()
])
if (!triggerBox || !labelBox) {
return Number.POSITIVE_INFINITY
}
return triggerBox.x + triggerBox.width - (labelBox.x + labelBox.width)
},
{ message: 'pet menu trigger still reserves trailing space after its label' }
)
.toBeLessThanOrEqual(MAX_TRAILING_CHROME_PX)
}
async function attachTriggerScreenshot(
page: Page,
testInfo: TestInfo,
name: string
): Promise<void> {
const trigger = page.getByRole('button', { name: 'Pet menu' })
const screenshotPath = testInfo.outputPath(`pet-status-trigger-${name}.png`)
await trigger.screenshot({ path: screenshotPath })
await testInfo.attach(`pet-status-trigger-${name}`, {
path: screenshotPath,
contentType: 'image/png'
})
}
test.describe('Pet status segment layout', () => {
test('does not reserve empty space after its label', async ({ orcaPage }, testInfo) => {
await waitForSessionReady(orcaPage)
await enableExperimentalPet(orcaPage)
for (const { name, size } of VIEWPORTS) {
await orcaPage.setViewportSize(size)
await assertPetTriggerFitsLabel(orcaPage)
await attachTriggerScreenshot(orcaPage, testInfo, name)
}
})
})