mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(codex): drop the resume argv when session provenance is unverifiable
An unverifiable Codex rollout used to fail the spawn with a red per-pane toast. The throw existed because returning null only declined to override CODEX_HOME — the renderer had already baked `codex resume <id>` into the command, so a fall-through would resume the session under whichever account is selected now. Drop the resume argv instead. Main decides provenance and now rewrites the launch command to plain `codex`, so there is nothing left to resume and the cross-account invariant holds by construction rather than by refusing to spawn. Verified resumes are unchanged: CODEX_HOME still pins to the originating home. The pane says so — SessionRestoredBanner gains a `resume-unavailable` variant, gated on claimsCodexRolloutLayout so stale cross-agent metadata stays silent. Closes #10793
This commit is contained in:
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
claimsCodexRolloutLayout,
|
||||
findTrustedCodexSessionResume,
|
||||
resolveCodexSessionResumeProvenance,
|
||||
resolveTrustedCodexSessionResumeHome
|
||||
} from './codex-session-resume-home'
|
||||
|
||||
@@ -269,3 +270,76 @@ describe('claimsCodexRolloutLayout', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCodexSessionResumeProvenance', () => {
|
||||
function writeRollout(sessionId: string): { homePath: string; rolloutPath: string } {
|
||||
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-provenance-'))
|
||||
tempRoots.push(homePath)
|
||||
const rolloutPath = join(
|
||||
homePath,
|
||||
'sessions',
|
||||
'2026',
|
||||
'07',
|
||||
'20',
|
||||
`rollout-2026-07-20T12-00-00-${sessionId}.jsonl`
|
||||
)
|
||||
mkdirSync(join(rolloutPath, '..'), { recursive: true })
|
||||
writeFileSync(rolloutPath, 'rollout')
|
||||
return { homePath, rolloutPath }
|
||||
}
|
||||
|
||||
it('starts fresh for a rollout file that really exists under a home Orca no longer trusts', async () => {
|
||||
// Why: the discriminating case — the file is present, so only the trust check can
|
||||
// reject it. Resuming here would run the session under the selected account.
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const removed = writeRollout(sessionId)
|
||||
const trusted = mkdtempSync(join(tmpdir(), 'orca-codex-resume-provenance-'))
|
||||
tempRoots.push(trusted)
|
||||
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId,
|
||||
transcriptPath: removed.rolloutPath,
|
||||
trustedCodexHomes: [trusted]
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'fresh', claimedCodexProvenance: true })
|
||||
})
|
||||
|
||||
it('resumes from the originating home when that home is still trusted', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const origin = writeRollout(sessionId)
|
||||
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId,
|
||||
transcriptPath: origin.rolloutPath,
|
||||
trustedCodexHomes: [origin.homePath]
|
||||
})
|
||||
).resolves.toEqual({
|
||||
outcome: 'resume',
|
||||
homePath: origin.homePath,
|
||||
transcriptPath: origin.rolloutPath
|
||||
})
|
||||
})
|
||||
|
||||
it('starts fresh silently for cross-agent provenance on a pane relabeled codex', async () => {
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11',
|
||||
transcriptPath: '/Users/example/.claude/projects/repo/019f81b9.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex']
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'fresh', claimedCodexProvenance: false })
|
||||
})
|
||||
|
||||
it('reports a missing rollout under a trusted home as rejected Codex provenance', async () => {
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11',
|
||||
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-gone.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
fileIsRegular: () => false
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'fresh', claimedCodexProvenance: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,15 @@ const ROLLOUT_RELATIVE_PATH = new RegExp(`^${DATED_ROLLOUT_TAIL}$`)
|
||||
// Why: case-insensitive because trusted-home matching folds Windows path case too.
|
||||
const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${DATED_ROLLOUT_TAIL}$`, 'i')
|
||||
|
||||
/** `resume` pins CODEX_HOME to the account that owns the rollout. `fresh` means
|
||||
* provenance could not be verified, so the caller drops the resume argv — an
|
||||
* unverifiable rollout must never resume under whichever account is selected now.
|
||||
* `claimedCodexProvenance` gates the user-facing notice: a path that claimed real
|
||||
* Codex layout is worth reporting, stale cross-agent metadata is not. */
|
||||
export type CodexSessionResumePreparation =
|
||||
| { outcome: 'resume'; codexHomePath: string }
|
||||
| { outcome: 'fresh'; claimedCodexProvenance: boolean }
|
||||
|
||||
function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean {
|
||||
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)
|
||||
return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/')))
|
||||
@@ -93,6 +102,27 @@ export function claimsCodexRolloutLayout(transcriptPath: string | undefined): bo
|
||||
return CODEX_ROLLOUT_LAYOUT_PATH.test(persistedPath.replace(/\\/g, '/'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Verified provenance, or an explicit fall-back to a fresh session. Never rejects:
|
||||
* the caller drops the resume argv on `fresh`, so a rollout Orca cannot place under a
|
||||
* trusted home resumes nowhere instead of resuming under the selected account (#10793).
|
||||
*/
|
||||
export async function resolveCodexSessionResumeProvenance(args: {
|
||||
sessionId: string
|
||||
transcriptPath: string | undefined
|
||||
trustedCodexHomes: readonly string[]
|
||||
fileIsRegular?: (filePath: string) => boolean
|
||||
listSessionFiles?: (sessionsRoot: string) => AsyncIterable<string>
|
||||
}): Promise<
|
||||
| { outcome: 'resume'; homePath: string; transcriptPath: string }
|
||||
| { outcome: 'fresh'; claimedCodexProvenance: boolean }
|
||||
> {
|
||||
const sessionSource = await findTrustedCodexSessionResume(args)
|
||||
return sessionSource
|
||||
? { outcome: 'resume', ...sessionSource }
|
||||
: { outcome: 'fresh', claimedCodexProvenance: claimsCodexRolloutLayout(args.transcriptPath) }
|
||||
}
|
||||
|
||||
export async function findTrustedCodexSessionResume(args: {
|
||||
sessionId: string
|
||||
transcriptPath: string | undefined
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { dropAgentResumeArgvFromCommand } from '../../shared/agent-resume-argv-drop'
|
||||
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
|
||||
|
||||
export const UNVERIFIED_CODEX_RESUME_ERROR =
|
||||
'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.'
|
||||
|
||||
/**
|
||||
* Launch command for a Codex resume whose originating account could not be verified:
|
||||
* the resume argv is dropped so the pane starts a clean session instead of resuming
|
||||
* under whichever account is selected now (#10793). Throws only when the resume
|
||||
* locator is present but not strippable — launching then could still cross accounts.
|
||||
*/
|
||||
export function dropUnverifiedCodexResumeArgv(args: {
|
||||
command: string | undefined
|
||||
providerSession: AgentProviderSessionMetadata | null
|
||||
}): { command: string | undefined; droppedResumeArgv: boolean } {
|
||||
if (!args.command || !args.providerSession) {
|
||||
return { command: args.command, droppedResumeArgv: false }
|
||||
}
|
||||
const drop = dropAgentResumeArgvFromCommand({
|
||||
command: args.command,
|
||||
agent: 'codex',
|
||||
providerSession: args.providerSession
|
||||
})
|
||||
if (drop.status === 'dropped') {
|
||||
return { command: drop.command, droppedResumeArgv: true }
|
||||
}
|
||||
if (drop.status === 'unrecognized') {
|
||||
throw new Error(UNVERIFIED_CODEX_RESUME_ERROR)
|
||||
}
|
||||
return { command: args.command, droppedResumeArgv: false }
|
||||
}
|
||||
+9
-14
@@ -173,8 +173,8 @@ import { createCodexSessionMigrationScheduler } from './codex/codex-session-migr
|
||||
import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume'
|
||||
import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home'
|
||||
import {
|
||||
claimsCodexRolloutLayout,
|
||||
findTrustedCodexSessionResume
|
||||
resolveCodexSessionResumeProvenance,
|
||||
type CodexSessionResumePreparation
|
||||
} from './codex/codex-session-resume-home'
|
||||
import { getSystemCodexHomePath } from './codex/codex-home-paths'
|
||||
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
|
||||
@@ -839,7 +839,7 @@ async function prepareCodexSessionResumeForLaunch(args: {
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
workspacePath?: string
|
||||
}): Promise<{ codexHomePath: string | null } | null> {
|
||||
}): Promise<CodexSessionResumePreparation | null> {
|
||||
if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) {
|
||||
return null
|
||||
}
|
||||
@@ -849,20 +849,15 @@ async function prepareCodexSessionResumeForLaunch(args: {
|
||||
systemHomePath,
|
||||
...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery()
|
||||
]
|
||||
const sessionSource = await findTrustedCodexSessionResume({
|
||||
const sessionSource = await resolveCodexSessionResumeProvenance({
|
||||
sessionId: args.providerSession.id,
|
||||
transcriptPath: args.providerSession.transcriptPath,
|
||||
trustedCodexHomes: trustedHomes
|
||||
})
|
||||
if (!sessionSource) {
|
||||
// Why: an unverifiable Codex rollout still blocks; only paths that never claimed Codex
|
||||
// provenance (e.g. ~/.claude/… on a pane mislabeled "codex") fall through to a normal launch.
|
||||
if (claimsCodexRolloutLayout(args.providerSession.transcriptPath)) {
|
||||
throw new Error(
|
||||
'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.'
|
||||
)
|
||||
}
|
||||
return null
|
||||
if (sessionSource.outcome === 'fresh') {
|
||||
// Why: unverifiable provenance drops the resume argv instead of failing the spawn —
|
||||
// with nothing to resume, cross-account resume is impossible by construction (#10793).
|
||||
return sessionSource
|
||||
}
|
||||
|
||||
let migrated = { useRealCodexHome: false }
|
||||
@@ -911,7 +906,7 @@ async function prepareCodexSessionResumeForLaunch(args: {
|
||||
// Why: hook repair is best-effort; session provenance must still win over the currently selected home.
|
||||
console.warn('[codex-hook-service] failed to prepare automatic resume home:', error)
|
||||
}
|
||||
return { codexHomePath: resumeHome }
|
||||
return { outcome: 'resume', codexHomePath: resumeHome }
|
||||
}
|
||||
|
||||
// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down.
|
||||
|
||||
+153
-4
@@ -2,6 +2,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { userInfo } from 'node:os'
|
||||
import { delimiter, join, posix } from 'node:path'
|
||||
import { resolveCodexSessionResumeProvenance } from '../codex/codex-session-resume-home'
|
||||
import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_MAX_BYTES
|
||||
@@ -1734,7 +1735,10 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
it('resumes an automatic Codex session from its prepared originating home', async () => {
|
||||
const selectedHome = vi.fn(() => '/managed/current/home')
|
||||
const prepareResume = vi.fn(async () => ({ codexHomePath: '/managed/origin/home' }))
|
||||
const prepareResume = vi.fn(async () => ({
|
||||
outcome: 'resume' as const,
|
||||
codexHomePath: '/managed/origin/home'
|
||||
}))
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
@@ -1779,7 +1783,12 @@ describe('registerPtyHandlers', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
|
||||
{
|
||||
prepareCodexSessionResume: async () => ({
|
||||
outcome: 'resume' as const,
|
||||
codexHomePath: systemHome
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
@@ -1837,6 +1846,136 @@ describe('registerPtyHandlers', () => {
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('unverifiable Codex resume provenance', () => {
|
||||
const RESUME_SESSION_ID = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const ORIGIN_HOME = '/managed/origin/home'
|
||||
const OTHER_HOME = '/managed/other/home'
|
||||
const ORIGIN_ROLLOUT = `${ORIGIN_HOME}/sessions/2026/07/20/rollout-2026-07-20T12-00-00-${RESUME_SESSION_ID}.jsonl`
|
||||
|
||||
// Why: main's real provenance rule, so these cases exercise the same decision
|
||||
// prepareCodexSessionResumeForLaunch makes instead of a hand-written stub. This
|
||||
// suite mocks fs, so the rollout is declared present — the only variable left is
|
||||
// whether its home is trusted, which is exactly the case the guard exists for.
|
||||
function registerWithTrustedHomes(trustedHomes: readonly string[], selectedHome: string) {
|
||||
const selectedHomeMock = vi.fn(() => selectedHome)
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
undefined,
|
||||
selectedHomeMock,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
prepareCodexSessionResume: async ({ providerSession }) => {
|
||||
const provenance = await resolveCodexSessionResumeProvenance({
|
||||
sessionId: providerSession.id,
|
||||
transcriptPath: providerSession.transcriptPath,
|
||||
trustedCodexHomes: trustedHomes,
|
||||
fileIsRegular: () => true
|
||||
})
|
||||
return provenance.outcome === 'resume'
|
||||
? { outcome: 'resume' as const, codexHomePath: provenance.homePath }
|
||||
: provenance
|
||||
}
|
||||
}
|
||||
)
|
||||
return selectedHomeMock
|
||||
}
|
||||
|
||||
async function spawnCodexResume(
|
||||
transcriptPath: string | undefined
|
||||
): Promise<{ agentResumeUnavailable?: true }> {
|
||||
return (await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
command: `codex 'resume' '${RESUME_SESSION_ID}'`,
|
||||
launchAgent: 'codex',
|
||||
resumeProviderSession: {
|
||||
key: 'session_id',
|
||||
id: RESUME_SESSION_ID,
|
||||
...(transcriptPath ? { transcriptPath } : {})
|
||||
}
|
||||
})) as { agentResumeUnavailable?: true }
|
||||
}
|
||||
|
||||
posixOnlyIt(
|
||||
'launches plain codex when a REAL rollout sits under a home Orca no longer trusts',
|
||||
async () => {
|
||||
// Why: the discriminating case — the rollout exists, so only the trust check can
|
||||
// reject it. Falling through would resume it under the selected account.
|
||||
const mockProc = createMockProc()
|
||||
spawnMock.mockReturnValue(mockProc.proc)
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const selectedHome = registerWithTrustedHomes([OTHER_HOME], OTHER_HOME)
|
||||
const spawned = await spawnCodexResume(ORIGIN_ROLLOUT)
|
||||
|
||||
await Promise.resolve()
|
||||
vi.runAllTimers()
|
||||
await Promise.resolve()
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockProc.proc.write).toHaveBeenCalledWith('codex\n')
|
||||
expect(mockProc.proc.write).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining(RESUME_SESSION_ID)
|
||||
)
|
||||
expect(spawned.agentResumeUnavailable).toBe(true)
|
||||
// The pane still runs under the selected account — but with nothing to resume.
|
||||
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
|
||||
expect(env.CODEX_HOME).toBe(OTHER_HOME)
|
||||
expect(selectedHome).toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('reports the dropped resume so the pane can say it started fresh', async () => {
|
||||
registerWithTrustedHomes([OTHER_HOME], OTHER_HOME)
|
||||
const spawned = await spawnCodexResume(ORIGIN_ROLLOUT)
|
||||
|
||||
expect(spawned.agentResumeUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
it('stays silent for cross-agent provenance on a pane relabeled codex', async () => {
|
||||
registerWithTrustedHomes([OTHER_HOME], OTHER_HOME)
|
||||
|
||||
const spawned = await spawnCodexResume(
|
||||
'/Users/example/.claude/projects/repo/019f81b9.jsonl'
|
||||
)
|
||||
|
||||
expect(spawned.agentResumeUnavailable).toBeUndefined()
|
||||
})
|
||||
|
||||
posixOnlyIt('still pins CODEX_HOME and resumes when provenance is verified', async () => {
|
||||
const mockProc = createMockProc()
|
||||
spawnMock.mockReturnValue(mockProc.proc)
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const selectedHome = registerWithTrustedHomes([ORIGIN_HOME], OTHER_HOME)
|
||||
const spawned = await spawnCodexResume(ORIGIN_ROLLOUT)
|
||||
|
||||
await Promise.resolve()
|
||||
vi.runAllTimers()
|
||||
await Promise.resolve()
|
||||
vi.runAllTimers()
|
||||
|
||||
expect(mockProc.proc.write).toHaveBeenCalledWith(
|
||||
`codex 'resume' '${RESUME_SESSION_ID}'\n`
|
||||
)
|
||||
expect(spawned.agentResumeUnavailable).toBeUndefined()
|
||||
const env = spawnMock.mock.calls.at(-1)![2].env as Record<string, string>
|
||||
expect(env.CODEX_HOME).toBe(ORIGIN_HOME)
|
||||
expect(selectedHome).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('prepares Codex launch state for the workspace before spawning an interactive tab', async () => {
|
||||
const workspacePath = '/repo/worktrees/new-feature'
|
||||
const resolveHome = vi.fn(
|
||||
@@ -2653,7 +2792,12 @@ describe('registerPtyHandlers', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
|
||||
{
|
||||
prepareCodexSessionResume: async () => ({
|
||||
outcome: 'resume' as const,
|
||||
codexHomePath: systemHome
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
@@ -2711,7 +2855,12 @@ describe('registerPtyHandlers', () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ prepareCodexSessionResume: async () => ({ codexHomePath: systemHome }) }
|
||||
{
|
||||
prepareCodexSessionResume: async () => ({
|
||||
outcome: 'resume' as const,
|
||||
codexHomePath: systemHome
|
||||
})
|
||||
}
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController
|
||||
|
||||
|
||||
+74
-18
@@ -163,6 +163,8 @@ import { validateTerminalViewAttributes } from '../../shared/terminal-view-attri
|
||||
import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag'
|
||||
import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home'
|
||||
import { dropUnverifiedCodexResumeArgv } from '../codex/codex-unverified-resume-launch'
|
||||
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
|
||||
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
|
||||
import { resolveSetupAgentSequenceLaunchCommand } from '../../shared/setup-agent-sequencing'
|
||||
@@ -787,7 +789,7 @@ export type PrepareCodexSessionResume = (args: {
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
workspacePath?: string
|
||||
}) => Promise<{ codexHomePath: string | null } | null>
|
||||
}) => Promise<CodexSessionResumePreparation | null>
|
||||
type PrepareClaudeAuth = (
|
||||
target?: ClaudeAccountSelectionTarget
|
||||
) => Promise<ClaudeRuntimeAuthPreparation>
|
||||
@@ -3126,7 +3128,10 @@ export function registerPtyHandlers(
|
||||
target: CodexAccountSelectionTarget
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
workspacePath?: string
|
||||
}): Promise<{ codexHomePath: string | null } | null> | null => {
|
||||
}): {
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
preparation: Promise<CodexSessionResumePreparation | null>
|
||||
} | null => {
|
||||
if (args.connectionId || args.launchAgent !== 'codex' || !options?.prepareCodexSessionResume) {
|
||||
return null
|
||||
}
|
||||
@@ -3134,14 +3139,52 @@ export function registerPtyHandlers(
|
||||
if (!providerSession) {
|
||||
return null
|
||||
}
|
||||
return options.prepareCodexSessionResume({
|
||||
return {
|
||||
providerSession,
|
||||
target: args.target,
|
||||
launchEnv: args.launchEnv,
|
||||
workspacePath: args.workspacePath
|
||||
})
|
||||
preparation: options.prepareCodexSessionResume({
|
||||
providerSession,
|
||||
target: args.target,
|
||||
launchEnv: args.launchEnv,
|
||||
workspacePath: args.workspacePath
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type CodexResumeLaunch = {
|
||||
codexResumeHome: { codexHomePath: string } | null
|
||||
command: string | undefined
|
||||
notifyResumeUnavailable: boolean
|
||||
}
|
||||
|
||||
/** Kept separate from resolveCodexResumeLaunch so non-Codex spawns never await:
|
||||
* an extra tick reorders the pane-spawn reservation races this handler arbitrates. */
|
||||
const noCodexResumeLaunch = (command: string | undefined): CodexResumeLaunch => ({
|
||||
codexResumeHome: null,
|
||||
command,
|
||||
notifyResumeUnavailable: false
|
||||
})
|
||||
|
||||
/** The command a Codex launch actually runs: unchanged when provenance is verified,
|
||||
* stripped of `resume <id>` when it is not. */
|
||||
const resolveCodexResumeLaunch = (
|
||||
command: string | undefined,
|
||||
preparation: NonNullable<ReturnType<typeof prepareCodexResumeHome>>
|
||||
): Promise<CodexResumeLaunch> =>
|
||||
preparation.preparation.then((prepared) => {
|
||||
if (prepared?.outcome !== 'fresh') {
|
||||
return { codexResumeHome: prepared ?? null, command, notifyResumeUnavailable: false }
|
||||
}
|
||||
const dropped = dropUnverifiedCodexResumeArgv({
|
||||
command,
|
||||
providerSession: preparation.providerSession
|
||||
})
|
||||
return {
|
||||
codexResumeHome: null,
|
||||
command: dropped.command,
|
||||
notifyResumeUnavailable: dropped.droppedResumeArgv && prepared.claimedCodexProvenance
|
||||
}
|
||||
})
|
||||
|
||||
// Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them.
|
||||
runtime?.setPtyController({
|
||||
spawn: async (args) => {
|
||||
@@ -3180,7 +3223,11 @@ export function registerPtyHandlers(
|
||||
launchEnv: args.env,
|
||||
workspacePath: cwd
|
||||
})
|
||||
const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null
|
||||
const codexResumeLaunch = codexResumePreparation
|
||||
? await resolveCodexResumeLaunch(args.command, codexResumePreparation)
|
||||
: noCodexResumeLaunch(args.command)
|
||||
const codexResumeHome = codexResumeLaunch.codexResumeHome
|
||||
const launchCommand = codexResumeLaunch.command
|
||||
const claudeAuth =
|
||||
isClaudeLaunch && prepareClaudeAuth ? await prepareClaudeAuth(codexSelectionTarget) : null
|
||||
if (isClaudeLaunch && isClaudeAuthSwitchInProgress()) {
|
||||
@@ -3286,7 +3333,7 @@ export function registerPtyHandlers(
|
||||
skipCodexHomeEnv,
|
||||
stripInheritedOrcaCodexHome,
|
||||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
||||
launchCommand: args.command,
|
||||
launchCommand,
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: daemonShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd),
|
||||
@@ -3347,8 +3394,8 @@ export function registerPtyHandlers(
|
||||
}
|
||||
deleteRequestedEnvKeys(env, spawnOptions.envToDelete)
|
||||
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
|
||||
if (args.command !== undefined) {
|
||||
spawnOptions.command = args.command
|
||||
if (launchCommand !== undefined) {
|
||||
spawnOptions.command = launchCommand
|
||||
}
|
||||
if (args.commandDelivery !== undefined) {
|
||||
spawnOptions.commandDelivery = args.commandDelivery
|
||||
@@ -3742,7 +3789,7 @@ export function registerPtyHandlers(
|
||||
runtime?.cancelPendingPtyRegistration?.(result.id, result.incarnationId)
|
||||
}
|
||||
// Why: arms main's per-PTY Command Code output detector from the launch command (renderer startupCommand parity).
|
||||
runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null)
|
||||
runtime?.noteTerminalSpawnCommand?.(result.id, launchCommand ?? null)
|
||||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
}
|
||||
@@ -4400,7 +4447,11 @@ export function registerPtyHandlers(
|
||||
launchEnv: baseEnv,
|
||||
workspacePath: cwd
|
||||
})
|
||||
const codexResumeHome = codexResumePreparation ? await codexResumePreparation : null
|
||||
const codexResumeLaunch = codexResumePreparation
|
||||
? await resolveCodexResumeLaunch(args.command, codexResumePreparation)
|
||||
: noCodexResumeLaunch(args.command)
|
||||
const codexResumeHome = codexResumeLaunch.codexResumeHome
|
||||
const launchCommand = codexResumeLaunch.command
|
||||
const selectedCodexHomePath = isDaemonHostSpawn
|
||||
? getCompatibleSelectedCodexHomePath(
|
||||
codexSelectionTarget,
|
||||
@@ -4444,7 +4495,7 @@ export function registerPtyHandlers(
|
||||
skipCodexHomeEnv,
|
||||
stripInheritedOrcaCodexHome,
|
||||
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false,
|
||||
launchCommand: args.command,
|
||||
launchCommand,
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
shellPath: effectiveShellOverride ?? process.env.COMSPEC,
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd),
|
||||
@@ -4504,8 +4555,8 @@ export function registerPtyHandlers(
|
||||
if (combinedEnvToDelete) {
|
||||
spawnOptions.envToDelete = combinedEnvToDelete
|
||||
}
|
||||
if (args.command !== undefined) {
|
||||
spawnOptions.command = args.command
|
||||
if (launchCommand !== undefined) {
|
||||
spawnOptions.command = launchCommand
|
||||
}
|
||||
if (args.commandDelivery !== undefined) {
|
||||
spawnOptions.commandDelivery = args.commandDelivery
|
||||
@@ -4877,7 +4928,7 @@ export function registerPtyHandlers(
|
||||
// Why: arm main's per-PTY Command Code output detector from the launch command (startupCommand parity); banner detection covers PTYs without one.
|
||||
runtime?.noteTerminalSpawnCommand?.(
|
||||
result.id,
|
||||
typeof args.command === 'string' ? args.command : null
|
||||
typeof launchCommand === 'string' ? launchCommand : null
|
||||
)
|
||||
if (isClaudeLaunch) {
|
||||
markClaudePtySpawned(result.id)
|
||||
@@ -4947,7 +4998,12 @@ export function registerPtyHandlers(
|
||||
? { launchConfig: effectiveLaunchConfig }
|
||||
: {}),
|
||||
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
|
||||
...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {})
|
||||
...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {}),
|
||||
// Why: the pane asked to resume and got a fresh session instead; only the
|
||||
// renderer can say so, and a reattach never ran this launch command.
|
||||
...(codexResumeLaunch.notifyResumeUnavailable && !result.isReattach
|
||||
? { agentResumeUnavailable: true as const }
|
||||
: {})
|
||||
}
|
||||
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
|
||||
sendPtySpawnedToRenderer(result.id)
|
||||
|
||||
@@ -1335,6 +1335,7 @@ export type PreloadApi = {
|
||||
sessionExpired?: boolean
|
||||
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
agentResumeUnavailable?: true
|
||||
}>
|
||||
write: (id: string, data: string) => void
|
||||
writeAccepted: (id: string, data: string) => Promise<boolean>
|
||||
|
||||
@@ -837,6 +837,7 @@ const api = {
|
||||
sessionExpired?: boolean
|
||||
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
agentResumeUnavailable?: true
|
||||
}> => ipcRenderer.invoke('pty:spawn', opts),
|
||||
|
||||
write: (id: string, data: string): void => {
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state'
|
||||
|
||||
export const SESSION_RESTORED_BANNER_TEXT = '--- session restored ---'
|
||||
export const SESSION_RESUME_UNAVAILABLE_BANNER_TEXT =
|
||||
'--- previous session unavailable, started fresh ---'
|
||||
|
||||
type SessionRestoredBannerProps = {
|
||||
visible: boolean
|
||||
reason?: SessionRestoredBannerReason
|
||||
}
|
||||
|
||||
export function SessionRestoredBanner({
|
||||
visible
|
||||
visible,
|
||||
reason = 'restored'
|
||||
}: SessionRestoredBannerProps): React.JSX.Element | null {
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className="session-restored-banner">{SESSION_RESTORED_BANNER_TEXT}</div>
|
||||
return (
|
||||
<div className="session-restored-banner">
|
||||
{reason === 'resume-unavailable'
|
||||
? SESSION_RESUME_UNAVAILABLE_BANNER_TEXT
|
||||
: SESSION_RESTORED_BANNER_TEXT}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { SessionRestoredBanner } from './SessionRestoredBanner'
|
||||
import type { SessionRestoredBannerPane } from './session-restored-banner-pane-state'
|
||||
import type {
|
||||
SessionRestoredBannerPane,
|
||||
SessionRestoredBannerPaneReasons
|
||||
} from './session-restored-banner-pane-state'
|
||||
|
||||
type SessionRestoredBannerPortalsProps = {
|
||||
panes: readonly SessionRestoredBannerPane[]
|
||||
paneIds: ReadonlySet<number>
|
||||
paneIds: SessionRestoredBannerPaneReasons
|
||||
}
|
||||
|
||||
export function SessionRestoredBannerPortals({
|
||||
@@ -14,13 +17,14 @@ export function SessionRestoredBannerPortals({
|
||||
return (
|
||||
<>
|
||||
{panes.map((pane) => {
|
||||
if (!paneIds.has(pane.id)) {
|
||||
const reason = paneIds.get(pane.id)
|
||||
if (!reason) {
|
||||
return null
|
||||
}
|
||||
return createPortal(
|
||||
// Why: resumed TUIs repaint xterm immediately, so the wake marker
|
||||
// must live in that pane's chrome instead of the PTY byte stream.
|
||||
<SessionRestoredBanner visible />,
|
||||
<SessionRestoredBanner visible reason={reason} />,
|
||||
pane.container,
|
||||
`session-restored-banner-${pane.id}`
|
||||
)
|
||||
|
||||
@@ -76,7 +76,8 @@ import {
|
||||
pruneSessionRestoredBannerPaneIds,
|
||||
removeSessionRestoredBannerPaneId,
|
||||
syncSessionRestoredBannerTitleSpace,
|
||||
type SessionRestoredBannerDismissEvent
|
||||
type SessionRestoredBannerDismissEvent,
|
||||
type SessionRestoredBannerReason
|
||||
} from './session-restored-banner-pane-state'
|
||||
import { useSystemPrefersDark } from './use-system-prefers-dark'
|
||||
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
|
||||
@@ -790,9 +791,9 @@ export default function TerminalPane({
|
||||
const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState(
|
||||
() => startup !== undefined && !isVisible
|
||||
)
|
||||
const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState<Set<number>>(
|
||||
() => new Set()
|
||||
)
|
||||
const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState<
|
||||
Map<number, SessionRestoredBannerReason>
|
||||
>(() => new Map())
|
||||
const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand)
|
||||
const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId])
|
||||
const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit)
|
||||
@@ -824,12 +825,15 @@ export default function TerminalPane({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const showRestoredSessionBanner = useCallback((paneId: number): void => {
|
||||
setSessionRestoredBannerPaneIds((prev) => {
|
||||
const next = addSessionRestoredBannerPaneId(prev, paneId)
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
}, [])
|
||||
const showRestoredSessionBanner = useCallback(
|
||||
(paneId: number, reason: SessionRestoredBannerReason = 'restored'): void => {
|
||||
setSessionRestoredBannerPaneIds((prev) => {
|
||||
const next = addSessionRestoredBannerPaneId(prev, paneId, reason)
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const dismissSessionRestoredBanner = useCallback(
|
||||
(event: SessionRestoredBannerDismissEvent): void => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state'
|
||||
import type { ReplayingPanesRef } from './replay-guard'
|
||||
import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport'
|
||||
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
|
||||
@@ -76,7 +77,7 @@ export type PtyConnectionDeps = {
|
||||
clearWorktreeUnread: (worktreeId: string) => void
|
||||
clearTerminalTabUnread: (tabId: string) => void
|
||||
clearTerminalPaneUnread: (paneKey: string) => void
|
||||
onShowSessionRestoredBanner: (paneId: number) => void
|
||||
onShowSessionRestoredBanner: (paneId: number, reason?: SessionRestoredBannerReason) => void
|
||||
// Why: the renderer dispatches two notification sources — BEL from the PTY
|
||||
// byte stream and agent-task-complete on the working→idle title transition.
|
||||
// shared/types.ts keeps a wider NotificationEventSource union because the
|
||||
|
||||
@@ -8102,7 +8102,7 @@ describe('connectPanePty', () => {
|
||||
const writeCalls = pane.terminal.write.mock.calls.map(([data]) => data)
|
||||
expect(writeCalls.findIndex((data) => data.includes('--- session restored ---'))).toBe(-1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored')
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -8121,6 +8121,63 @@ describe('connectPanePty', () => {
|
||||
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
it('marks the pane as freshly started when main declined an unverifiable resume', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('fresh-pty')
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
|
||||
if (sessionId) {
|
||||
// Why: main dropped `resume <id>` because it could not verify which Codex
|
||||
// account owns the rollout, so this PTY is a new session, not a restore.
|
||||
return {
|
||||
id: 'fresh-pty',
|
||||
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' },
|
||||
agentResumeUnavailable: true as const
|
||||
}
|
||||
}
|
||||
return 'fresh-pty'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable')
|
||||
})
|
||||
|
||||
it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('fresh-pty')
|
||||
@@ -8184,7 +8241,7 @@ describe('connectPanePty', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('cold-payload', expect.any(Function))
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'restored')
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(transport.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -8694,7 +8751,7 @@ describe('connectPanePty', () => {
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored')
|
||||
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
@@ -8797,7 +8854,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2)
|
||||
expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(2, 'restored')
|
||||
expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
type HasPty
|
||||
} from './terminal-dead-session-reconcile'
|
||||
import type { PtyConnectionDeps } from './pty-connection-types'
|
||||
import type { SessionRestoredBannerReason } from './session-restored-banner-pane-state'
|
||||
import {
|
||||
consumeCommittedPtyShutdownExit,
|
||||
deferPtyShutdownExit,
|
||||
@@ -4502,12 +4503,12 @@ export function connectPanePty(
|
||||
armStartupDraftReadinessObservation()
|
||||
}
|
||||
let sessionRestoredBannerShown = false
|
||||
const showSessionRestoredBanner = (): void => {
|
||||
const showSessionRestoredBanner = (reason: SessionRestoredBannerReason = 'restored'): void => {
|
||||
if (sessionRestoredBannerShown) {
|
||||
return
|
||||
}
|
||||
sessionRestoredBannerShown = true
|
||||
deps.onShowSessionRestoredBanner(pane.id)
|
||||
deps.onShowSessionRestoredBanner(pane.id, reason)
|
||||
}
|
||||
const getColdRestoreAgentResumePlatform = (): NodeJS.Platform => {
|
||||
if (projectRuntime?.status === 'repair-required') {
|
||||
@@ -4849,7 +4850,15 @@ export function connectPanePty(
|
||||
foreground: shouldWritePtyOutputForeground(deps.isVisibleRef.current)
|
||||
})
|
||||
}
|
||||
if (coldRestoreOverride?.hasSleepingRecord) {
|
||||
if (
|
||||
spawnedPtyId &&
|
||||
typeof spawnedPtyId === 'object' &&
|
||||
spawnedPtyId.agentResumeUnavailable
|
||||
) {
|
||||
// Why: main dropped the resume argv, so this pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
showSessionRestoredBanner('resume-unavailable')
|
||||
} else if (coldRestoreOverride?.hasSleepingRecord) {
|
||||
showSessionRestoredBanner()
|
||||
}
|
||||
clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride)
|
||||
@@ -7443,7 +7452,11 @@ export function connectPanePty(
|
||||
const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup()
|
||||
const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup)
|
||||
if (didPrepareResume) {
|
||||
if (preparedStartup?.hasSleepingRecord) {
|
||||
if (connectResult.agentResumeUnavailable) {
|
||||
// Why: main dropped the resume argv, so this pane is a NEW session —
|
||||
// the plain restored banner would claim the old one came back.
|
||||
showSessionRestoredBanner('resume-unavailable')
|
||||
} else if (preparedStartup?.hasSleepingRecord) {
|
||||
showSessionRestoredBanner()
|
||||
}
|
||||
clearSleepingRecordAfterColdRestoreSpawn(preparedStartup)
|
||||
|
||||
@@ -63,6 +63,8 @@ export type PtyConnectResult = {
|
||||
coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number }
|
||||
replay?: string
|
||||
startupCwdFallback?: { kind: 'worktree'; cwd: string }
|
||||
/** Main declined an unverifiable provider-session resume and launched fresh. */
|
||||
agentResumeUnavailable?: true
|
||||
/** Trailing partial escape the daemon emulator held mid-parse; the reattach
|
||||
* replay writes it LAST (after the reset) so a racing live continuation
|
||||
* completes it instead of rendering literally (#7329). */
|
||||
|
||||
@@ -480,6 +480,30 @@ describe('createIpcPtyTransport', () => {
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('forwards the declined-resume signal on fresh and cold-restore spawns alike', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
spawn.mockResolvedValueOnce({ id: 'pty-1', agentResumeUnavailable: true })
|
||||
|
||||
const freshTransport = createIpcPtyTransport({})
|
||||
await expect(freshTransport.connect({ url: '', callbacks: {} })).resolves.toEqual({
|
||||
id: 'pty-1',
|
||||
agentResumeUnavailable: true
|
||||
})
|
||||
freshTransport.disconnect()
|
||||
|
||||
spawn.mockResolvedValueOnce({
|
||||
id: 'pty-2',
|
||||
coldRestore: { scrollback: 'recovered', cwd: '/repo/app' },
|
||||
agentResumeUnavailable: true
|
||||
})
|
||||
const coldTransport = createIpcPtyTransport({})
|
||||
await expect(coldTransport.connect({ url: '', callbacks: {} })).resolves.toEqual(
|
||||
expect.objectContaining({ id: 'pty-2', agentResumeUnavailable: true })
|
||||
)
|
||||
coldTransport.disconnect()
|
||||
})
|
||||
|
||||
it('defers title side effects until after terminal data is delivered', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const onTitleChange = vi.fn()
|
||||
|
||||
@@ -804,17 +804,26 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
sessionExpired: spawnResult.sessionExpired,
|
||||
coldRestore: spawnResult.coldRestore,
|
||||
replay: spawnResult.replay,
|
||||
pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi
|
||||
pendingEscapeTailAnsi: spawnResult.pendingEscapeTailAnsi,
|
||||
// Why: the cold-restore path re-runs the launch command, so it needs the
|
||||
// same "main declined the resume" signal the fresh-spawn path gets.
|
||||
...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {})
|
||||
} satisfies PtyConnectResult
|
||||
}
|
||||
if (resultLaunchAgent || spawnResult.launchConfig || spawnResult.startupCwdFallback) {
|
||||
if (
|
||||
resultLaunchAgent ||
|
||||
spawnResult.launchConfig ||
|
||||
spawnResult.startupCwdFallback ||
|
||||
spawnResult.agentResumeUnavailable
|
||||
) {
|
||||
return {
|
||||
id: spawnResult.id,
|
||||
...(resultLaunchAgent ? { launchAgent: resultLaunchAgent } : {}),
|
||||
...(spawnResult.launchConfig ? { launchConfig: spawnResult.launchConfig } : {}),
|
||||
...(spawnResult.startupCwdFallback
|
||||
? { startupCwdFallback: spawnResult.startupCwdFallback }
|
||||
: {})
|
||||
: {}),
|
||||
...(spawnResult.agentResumeUnavailable ? { agentResumeUnavailable: true as const } : {})
|
||||
} satisfies PtyConnectResult
|
||||
}
|
||||
return spawnResult.id
|
||||
|
||||
+43
-19
@@ -3,7 +3,10 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { SESSION_RESTORED_BANNER_TEXT } from './SessionRestoredBanner'
|
||||
import {
|
||||
SESSION_RESTORED_BANNER_TEXT,
|
||||
SESSION_RESUME_UNAVAILABLE_BANNER_TEXT
|
||||
} from './SessionRestoredBanner'
|
||||
import { SessionRestoredBannerPortals } from './SessionRestoredBannerPortals'
|
||||
import {
|
||||
addSessionRestoredBannerPaneId,
|
||||
@@ -12,7 +15,8 @@ import {
|
||||
removeSessionRestoredBannerPaneId,
|
||||
seedStartupSessionRestoredBanner,
|
||||
syncSessionRestoredBannerTitleSpace,
|
||||
type SessionRestoredBannerPane
|
||||
type SessionRestoredBannerPane,
|
||||
type SessionRestoredBannerPaneReasons
|
||||
} from './session-restored-banner-pane-state'
|
||||
|
||||
const mountedRoots: Root[] = []
|
||||
@@ -27,7 +31,7 @@ function createPane(id: number): SessionRestoredBannerPane {
|
||||
|
||||
async function renderPortals(
|
||||
panes: readonly SessionRestoredBannerPane[],
|
||||
paneIds: ReadonlySet<number>
|
||||
paneIds: SessionRestoredBannerPaneReasons
|
||||
): Promise<void> {
|
||||
const rootContainer = document.createElement('div')
|
||||
document.body.appendChild(rootContainer)
|
||||
@@ -43,6 +47,10 @@ function eventFrom(target: HTMLElement, event: KeyboardEvent | PointerEvent): ty
|
||||
return event
|
||||
}
|
||||
|
||||
function bannerReasons(paneIds: readonly number[]): SessionRestoredBannerPaneReasons {
|
||||
return new Map(paneIds.map((paneId) => [paneId, 'restored' as const]))
|
||||
}
|
||||
|
||||
function paneText(pane: SessionRestoredBannerPane): string {
|
||||
return pane.container.textContent ?? ''
|
||||
}
|
||||
@@ -60,7 +68,7 @@ describe('session restored banner pane state', () => {
|
||||
it('seeds sidebar startup onto the created pane and renders its overlay there', async () => {
|
||||
const firstPane = createPane(1)
|
||||
const createdPane = createPane(2)
|
||||
let paneIds = new Set<number>()
|
||||
let paneIds: SessionRestoredBannerPaneReasons = new Map()
|
||||
|
||||
seedStartupSessionRestoredBanner(
|
||||
{ showSessionRestoredBanner: true },
|
||||
@@ -71,7 +79,7 @@ describe('session restored banner pane state', () => {
|
||||
)
|
||||
await renderPortals([firstPane, createdPane], paneIds)
|
||||
|
||||
expect(paneIds).toEqual(new Set([createdPane.id]))
|
||||
expect(paneIds).toEqual(new Map([[createdPane.id, 'restored']]))
|
||||
expect(paneText(firstPane)).toBe('')
|
||||
expect(paneText(createdPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
|
||||
})
|
||||
@@ -84,7 +92,7 @@ describe('session restored banner pane state', () => {
|
||||
panes: [activePane, secondPane],
|
||||
paneTitles: {},
|
||||
renamingPaneId: null,
|
||||
sessionRestoredBannerPaneIds: new Set()
|
||||
sessionRestoredBannerPaneIds: new Map()
|
||||
})
|
||||
|
||||
expect(needsFit).toBe(false)
|
||||
@@ -100,7 +108,7 @@ describe('session restored banner pane state', () => {
|
||||
panes: [titledPane, renamingPane],
|
||||
paneTitles: { [titledPane.id]: 'server' },
|
||||
renamingPaneId: renamingPane.id,
|
||||
sessionRestoredBannerPaneIds: new Set()
|
||||
sessionRestoredBannerPaneIds: new Map()
|
||||
})
|
||||
|
||||
expect(needsFit).toBe(true)
|
||||
@@ -111,7 +119,7 @@ describe('session restored banner pane state', () => {
|
||||
it('renders and reserves title space only on the restored inactive split pane', async () => {
|
||||
const activePane = createPane(1)
|
||||
const inactiveRestoredPane = createPane(2)
|
||||
const paneIds = new Set([inactiveRestoredPane.id])
|
||||
const paneIds = new Map<number, 'restored'>([[inactiveRestoredPane.id, 'restored']])
|
||||
|
||||
const needsFit = syncSessionRestoredBannerTitleSpace({
|
||||
panes: [activePane, inactiveRestoredPane],
|
||||
@@ -128,6 +136,22 @@ describe('session restored banner pane state', () => {
|
||||
expect(paneText(inactiveRestoredPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
|
||||
})
|
||||
|
||||
it('names a fresh session when the requested resume could not be verified', async () => {
|
||||
const restoredPane = createPane(1)
|
||||
const freshPane = createPane(2)
|
||||
|
||||
await renderPortals(
|
||||
[restoredPane, freshPane],
|
||||
new Map([
|
||||
[restoredPane.id, 'restored' as const],
|
||||
[freshPane.id, 'resume-unavailable' as const]
|
||||
])
|
||||
)
|
||||
|
||||
expect(paneText(restoredPane)).toBe(SESSION_RESTORED_BANNER_TEXT)
|
||||
expect(paneText(freshPane)).toBe(SESSION_RESUME_UNAVAILABLE_BANNER_TEXT)
|
||||
})
|
||||
|
||||
it('dismisses only the interacted pane for pointer and key events', () => {
|
||||
const firstPane = createPane(1)
|
||||
const secondPane = createPane(2)
|
||||
@@ -137,18 +161,18 @@ describe('session restored banner pane state', () => {
|
||||
secondPane.container.appendChild(secondChild)
|
||||
|
||||
const afterPointer = dismissSessionRestoredBannerPaneIds(
|
||||
new Set([firstPane.id, secondPane.id]),
|
||||
bannerReasons([firstPane.id, secondPane.id]),
|
||||
eventFrom(secondChild, new PointerEvent('pointerdown', { bubbles: true })),
|
||||
[firstPane, secondPane]
|
||||
)
|
||||
const afterKey = dismissSessionRestoredBannerPaneIds(
|
||||
new Set([firstPane.id, secondPane.id]),
|
||||
bannerReasons([firstPane.id, secondPane.id]),
|
||||
eventFrom(firstChild, new KeyboardEvent('keydown', { bubbles: true })),
|
||||
[firstPane, secondPane]
|
||||
)
|
||||
|
||||
expect(afterPointer).toEqual(new Set([firstPane.id]))
|
||||
expect(afterKey).toEqual(new Set([secondPane.id]))
|
||||
expect(afterPointer).toEqual(bannerReasons([firstPane.id]))
|
||||
expect(afterKey).toEqual(bannerReasons([secondPane.id]))
|
||||
})
|
||||
|
||||
it('clears all restored banners when dismissal cannot resolve a pane', () => {
|
||||
@@ -158,23 +182,23 @@ describe('session restored banner pane state', () => {
|
||||
document.body.appendChild(outside)
|
||||
|
||||
const afterDismiss = dismissSessionRestoredBannerPaneIds(
|
||||
new Set([firstPane.id, secondPane.id]),
|
||||
bannerReasons([firstPane.id, secondPane.id]),
|
||||
eventFrom(outside, new PointerEvent('pointerdown', { bubbles: true })),
|
||||
[firstPane, secondPane]
|
||||
)
|
||||
|
||||
expect(afterDismiss).toEqual(new Set())
|
||||
expect(afterDismiss).toEqual(new Map())
|
||||
})
|
||||
|
||||
it('clears banners for closed or removed panes', () => {
|
||||
const firstPane = createPane(1)
|
||||
const secondPane = createPane(2)
|
||||
|
||||
expect(removeSessionRestoredBannerPaneId(new Set([firstPane.id, secondPane.id]), 2)).toEqual(
|
||||
new Set([firstPane.id])
|
||||
)
|
||||
expect(
|
||||
pruneSessionRestoredBannerPaneIds(new Set([firstPane.id, secondPane.id]), [firstPane])
|
||||
).toEqual(new Set([firstPane.id]))
|
||||
removeSessionRestoredBannerPaneId(bannerReasons([firstPane.id, secondPane.id]), 2)
|
||||
).toEqual(bannerReasons([firstPane.id]))
|
||||
expect(
|
||||
pruneSessionRestoredBannerPaneIds(bannerReasons([firstPane.id, secondPane.id]), [firstPane])
|
||||
).toEqual(bannerReasons([firstPane.id]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
|
||||
export type SessionRestoredBannerPane = Pick<ManagedPane, 'id' | 'container'>
|
||||
|
||||
/** `resume-unavailable`: the pane asked to resume a provider session Orca could not
|
||||
* verify, so it launched a fresh one — silence would read as a successful restore. */
|
||||
export type SessionRestoredBannerReason = 'restored' | 'resume-unavailable'
|
||||
|
||||
export type SessionRestoredBannerPaneReasons = ReadonlyMap<number, SessionRestoredBannerReason>
|
||||
|
||||
export type SessionRestoredBannerStartup =
|
||||
| {
|
||||
showSessionRestoredBanner?: boolean
|
||||
@@ -12,36 +18,37 @@ export type SessionRestoredBannerStartup =
|
||||
export type SessionRestoredBannerDismissEvent = KeyboardEvent | PointerEvent
|
||||
|
||||
export function addSessionRestoredBannerPaneId(
|
||||
paneIds: ReadonlySet<number>,
|
||||
paneId: number
|
||||
): Set<number> {
|
||||
if (paneIds.has(paneId)) {
|
||||
return paneIds instanceof Set ? paneIds : new Set(paneIds)
|
||||
paneReasons: SessionRestoredBannerPaneReasons,
|
||||
paneId: number,
|
||||
reason: SessionRestoredBannerReason = 'restored'
|
||||
): Map<number, SessionRestoredBannerReason> {
|
||||
if (paneReasons.get(paneId) === reason) {
|
||||
return paneReasons instanceof Map ? paneReasons : new Map(paneReasons)
|
||||
}
|
||||
return new Set(paneIds).add(paneId)
|
||||
return new Map(paneReasons).set(paneId, reason)
|
||||
}
|
||||
|
||||
export function removeSessionRestoredBannerPaneId(
|
||||
paneIds: ReadonlySet<number>,
|
||||
paneReasons: SessionRestoredBannerPaneReasons,
|
||||
paneId: number
|
||||
): Set<number> {
|
||||
if (!paneIds.has(paneId)) {
|
||||
return paneIds instanceof Set ? paneIds : new Set(paneIds)
|
||||
): Map<number, SessionRestoredBannerReason> {
|
||||
if (!paneReasons.has(paneId)) {
|
||||
return paneReasons instanceof Map ? paneReasons : new Map(paneReasons)
|
||||
}
|
||||
const next = new Set(paneIds)
|
||||
const next = new Map(paneReasons)
|
||||
next.delete(paneId)
|
||||
return next
|
||||
}
|
||||
|
||||
export function pruneSessionRestoredBannerPaneIds(
|
||||
paneIds: ReadonlySet<number>,
|
||||
paneReasons: SessionRestoredBannerPaneReasons,
|
||||
panes: readonly SessionRestoredBannerPane[]
|
||||
): Set<number> {
|
||||
): Map<number, SessionRestoredBannerReason> {
|
||||
const livePaneIds = new Set(panes.map((pane) => pane.id))
|
||||
if ([...paneIds].every((paneId) => livePaneIds.has(paneId))) {
|
||||
return paneIds instanceof Set ? paneIds : new Set(paneIds)
|
||||
if ([...paneReasons.keys()].every((paneId) => livePaneIds.has(paneId))) {
|
||||
return paneReasons instanceof Map ? paneReasons : new Map(paneReasons)
|
||||
}
|
||||
return new Set([...paneIds].filter((paneId) => livePaneIds.has(paneId)))
|
||||
return new Map([...paneReasons].filter(([paneId]) => livePaneIds.has(paneId)))
|
||||
}
|
||||
|
||||
export function getSessionRestoredBannerDismissPaneId(
|
||||
@@ -62,21 +69,21 @@ export function getSessionRestoredBannerDismissPaneId(
|
||||
}
|
||||
|
||||
export function dismissSessionRestoredBannerPaneIds(
|
||||
paneIds: ReadonlySet<number>,
|
||||
paneReasons: SessionRestoredBannerPaneReasons,
|
||||
event: SessionRestoredBannerDismissEvent,
|
||||
panes: readonly SessionRestoredBannerPane[]
|
||||
): Set<number> {
|
||||
): Map<number, SessionRestoredBannerReason> {
|
||||
const paneId = getSessionRestoredBannerDismissPaneId(event, panes)
|
||||
if (paneId === null) {
|
||||
return new Set()
|
||||
return new Map()
|
||||
}
|
||||
return removeSessionRestoredBannerPaneId(paneIds, paneId)
|
||||
return removeSessionRestoredBannerPaneId(paneReasons, paneId)
|
||||
}
|
||||
|
||||
export function seedStartupSessionRestoredBanner(
|
||||
startup: SessionRestoredBannerStartup,
|
||||
paneId: number,
|
||||
onShowSessionRestoredBanner: (paneId: number) => void
|
||||
onShowSessionRestoredBanner: (paneId: number, reason?: SessionRestoredBannerReason) => void
|
||||
): void {
|
||||
if (startup?.showSessionRestoredBanner === true) {
|
||||
onShowSessionRestoredBanner(paneId)
|
||||
@@ -87,7 +94,7 @@ export function syncSessionRestoredBannerTitleSpace(args: {
|
||||
panes: readonly SessionRestoredBannerPane[]
|
||||
paneTitles: Readonly<Record<number, string>>
|
||||
renamingPaneId: number | null
|
||||
sessionRestoredBannerPaneIds: ReadonlySet<number>
|
||||
sessionRestoredBannerPaneIds: SessionRestoredBannerPaneReasons
|
||||
}): boolean {
|
||||
let needsFit = false
|
||||
for (const pane of args.panes) {
|
||||
|
||||
@@ -131,7 +131,10 @@ import {
|
||||
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
|
||||
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state'
|
||||
import {
|
||||
seedStartupSessionRestoredBanner,
|
||||
type SessionRestoredBannerReason
|
||||
} from './session-restored-banner-pane-state'
|
||||
import {
|
||||
resolveTabTitleAfterPaneClose,
|
||||
shouldClearLaunchAgentForClosedPane
|
||||
@@ -281,7 +284,7 @@ type UseTerminalPaneLifecycleDeps = {
|
||||
clearWorktreeUnread: (worktreeId: string) => void
|
||||
clearTerminalTabUnread: (tabId: string) => void
|
||||
clearTerminalPaneUnread: (paneKey: string) => void
|
||||
onShowSessionRestoredBanner: (paneId: number) => void
|
||||
onShowSessionRestoredBanner: (paneId: number, reason?: SessionRestoredBannerReason) => void
|
||||
dispatchNotification: (event: {
|
||||
source: 'terminal-bell' | 'agent-task-complete'
|
||||
terminalTitle?: string
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dropAgentResumeArgvFromCommand } from './agent-resume-argv-drop'
|
||||
import { buildAgentResumeStartupPlan } from './tui-agent-startup'
|
||||
|
||||
const CODEX_SESSION = {
|
||||
key: 'session_id' as const,
|
||||
id: '0199aa11-2b3c-4d5e-8f90-a1b2c3d4e5f6'
|
||||
}
|
||||
|
||||
function planFor(platform: NodeJS.Platform, agentArgs?: string): string {
|
||||
const plan = buildAgentResumeStartupPlan({
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION,
|
||||
cmdOverrides: {},
|
||||
platform,
|
||||
...(agentArgs ? { agentArgs } : {})
|
||||
})
|
||||
if (!plan) {
|
||||
throw new Error('expected a codex resume plan')
|
||||
}
|
||||
return plan.launchCommand
|
||||
}
|
||||
|
||||
describe('dropAgentResumeArgvFromCommand', () => {
|
||||
it('drops the resume argv the shared builder appended on every platform', () => {
|
||||
for (const platform of ['darwin', 'linux', 'win32'] as const) {
|
||||
const command = planFor(platform)
|
||||
expect(command).toContain(CODEX_SESSION.id)
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: 'codex' })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the user CLI args that precede the resume argv', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: planFor('darwin', '--search'),
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: "codex '--search'" })
|
||||
})
|
||||
|
||||
it('drops an unquoted resume argv from a legacy persisted command', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `codex resume ${CODEX_SESSION.id}`,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: 'codex' })
|
||||
})
|
||||
|
||||
it('reports absent when the command never carried the resume locator', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: 'codex --search',
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'absent' })
|
||||
})
|
||||
|
||||
it('refuses a command whose locator is not the trailing argv', () => {
|
||||
// Why: the caller must fail the spawn rather than launch a resume it could not cancel.
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `codex resume ${CODEX_SESSION.id} --search`,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'unrecognized' })
|
||||
})
|
||||
|
||||
it('requires a whitespace boundary so a longer subcommand cannot match', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `codex xresume ${CODEX_SESSION.id}`,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'unrecognized' })
|
||||
})
|
||||
|
||||
it('does not strip a bare resume argv with no launch command in front of it', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `resume ${CODEX_SESSION.id}`,
|
||||
agent: 'codex',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'unrecognized' })
|
||||
})
|
||||
|
||||
it('drops the resume argv for the other resumable agents too', () => {
|
||||
expect(
|
||||
dropAgentResumeArgvFromCommand({
|
||||
command: `claude '--resume' '${CODEX_SESSION.id}'`,
|
||||
agent: 'claude',
|
||||
providerSession: CODEX_SESSION
|
||||
})
|
||||
).toEqual({ status: 'dropped', command: 'claude' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
getAgentResumeArgv,
|
||||
type AgentProviderSessionMetadata,
|
||||
type ResumableTuiAgent
|
||||
} from './agent-session-resume'
|
||||
import { quoteStartupArg, type AgentStartupShell } from './tui-agent-startup-shell'
|
||||
|
||||
/** `dropped` carries the plain launch command; `unrecognized` means the resume
|
||||
* locator is still in there and the caller must refuse the launch rather than
|
||||
* run a resume it could not cancel. */
|
||||
export type AgentResumeArgvDrop =
|
||||
| { status: 'dropped'; command: string }
|
||||
| { status: 'absent' }
|
||||
| { status: 'unrecognized' }
|
||||
|
||||
const RESUME_ARGV_SHELLS: readonly AgentStartupShell[] = ['posix', 'powershell', 'cmd']
|
||||
|
||||
function resumeArgvSuffixCandidates(resumeArgs: readonly string[]): string[] {
|
||||
const candidates = new Set<string>([resumeArgs.join(' ')])
|
||||
for (const shell of RESUME_ARGV_SHELLS) {
|
||||
candidates.add(resumeArgs.map((arg) => quoteStartupArg(arg, shell)).join(' '))
|
||||
}
|
||||
return [...candidates]
|
||||
}
|
||||
|
||||
function stripSuffix(command: string, suffix: string): string | null {
|
||||
const base = command.slice(0, command.length - suffix.length)
|
||||
// Why: require a whitespace boundary so `codex resume-foo` can't match `resume`.
|
||||
if (!command.endsWith(suffix) || base.length === 0 || !/\s$/.test(base)) {
|
||||
return null
|
||||
}
|
||||
const trimmed = base.trimEnd()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the resume argv `buildAgentResumeStartupPlan` appended, leaving the plain
|
||||
* agent launch. Main uses this when it cannot verify which account owns the session,
|
||||
* so the pane starts fresh instead of resuming under whichever account is selected.
|
||||
*/
|
||||
export function dropAgentResumeArgvFromCommand(args: {
|
||||
command: string
|
||||
agent: ResumableTuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
ompResumeFilePath?: string | null
|
||||
}): AgentResumeArgvDrop {
|
||||
const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
|
||||
const resumeArgs = argv?.slice(1) ?? []
|
||||
const locator = resumeArgs.at(-1)
|
||||
const command = args.command.trimEnd()
|
||||
if (!locator || !command.includes(locator)) {
|
||||
return { status: 'absent' }
|
||||
}
|
||||
for (const suffix of resumeArgvSuffixCandidates(resumeArgs)) {
|
||||
const base = stripSuffix(command, suffix)
|
||||
if (base !== null && !base.includes(locator)) {
|
||||
return { status: 'dropped', command: base }
|
||||
}
|
||||
}
|
||||
return { status: 'unrecognized' }
|
||||
}
|
||||
Reference in New Issue
Block a user