mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(terminal): retain sleeping agent records until spawn
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
applyTerminalPaneCloseRequest,
|
||||
applyTerminalScrollbackRowsToMountedPanes,
|
||||
clearQueuedInitialCwdAfterFirstPane,
|
||||
consumeQueuedStartupAndClearSleepingRecord,
|
||||
createQueuedStartupConsumer,
|
||||
getPreviousVisibleForTerminalPane,
|
||||
isTerminalPaneVisibilityResume,
|
||||
@@ -301,6 +302,49 @@ describe('createQueuedStartupConsumer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('consumeQueuedStartupAndClearSleepingRecord', () => {
|
||||
it('clears the matching record once after fresh-spawn startup consumption', () => {
|
||||
const identity = { paneKey: 'old-tab:leaf-1', capturedAt: 42 }
|
||||
let consumed = true
|
||||
const clearRecord = vi.fn()
|
||||
|
||||
const consume = (): { sleepingAgentResumeIdentity: typeof identity } | null => {
|
||||
if (!consumed) {
|
||||
return null
|
||||
}
|
||||
consumed = false
|
||||
return { sleepingAgentResumeIdentity: identity }
|
||||
}
|
||||
|
||||
consumeQueuedStartupAndClearSleepingRecord(
|
||||
'tab-1',
|
||||
consume,
|
||||
() => ({ capturedAt: 42 }),
|
||||
clearRecord
|
||||
)
|
||||
consumeQueuedStartupAndClearSleepingRecord(
|
||||
'tab-1',
|
||||
consume,
|
||||
() => ({ capturedAt: 42 }),
|
||||
clearRecord
|
||||
)
|
||||
|
||||
expect(clearRecord).toHaveBeenCalledOnce()
|
||||
expect(clearRecord).toHaveBeenCalledWith(identity.paneKey)
|
||||
})
|
||||
|
||||
it('does not clear a replacement capture on the same pane key', () => {
|
||||
const clearRecord = vi.fn()
|
||||
consumeQueuedStartupAndClearSleepingRecord(
|
||||
'tab-1',
|
||||
() => ({ sleepingAgentResumeIdentity: { paneKey: 'tab-1:leaf-1', capturedAt: 41 } }),
|
||||
() => ({ capturedAt: 42 }),
|
||||
clearRecord
|
||||
)
|
||||
expect(clearRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitPaneWithOneShotStartup', () => {
|
||||
it('only exposes startup to the intentional split and clears it afterwards', () => {
|
||||
const deps: { startup?: { command: string; env?: Record<string, string> } | null } = {
|
||||
|
||||
@@ -494,6 +494,22 @@ export function paneOwnsQueuedStartup(
|
||||
return queuedStartup != null && paneStartup === queuedStartup
|
||||
}
|
||||
|
||||
export type SleepingAgentResumeIdentity = { paneKey: string; capturedAt: number }
|
||||
|
||||
export function consumeQueuedStartupAndClearSleepingRecord(
|
||||
tabId: string,
|
||||
consume: (tabId: string) => { sleepingAgentResumeIdentity?: SleepingAgentResumeIdentity } | null,
|
||||
getRecord: (paneKey: string) => { capturedAt: number } | undefined,
|
||||
clearRecord: (paneKey: string) => void
|
||||
): void {
|
||||
const consumed = consume(tabId)
|
||||
const identity = consumed?.sleepingAgentResumeIdentity
|
||||
const record = identity ? getRecord(identity.paneKey) : undefined
|
||||
if (identity && record?.capturedAt === identity.capturedAt) {
|
||||
clearRecord(identity.paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback that spends the tab's queued startup command, or `undefined` when this pane does
|
||||
* not own it.
|
||||
@@ -1351,7 +1367,15 @@ export function useTerminalPaneLifecycle({
|
||||
const onQueuedStartupSpawned = createQueuedStartupConsumer(
|
||||
ptyDeps.startup,
|
||||
startupWithSetupSplitWait,
|
||||
() => useAppStore.getState().consumeTabStartupCommand(tabId),
|
||||
() => {
|
||||
const state = useAppStore.getState()
|
||||
consumeQueuedStartupAndClearSleepingRecord(
|
||||
tabId,
|
||||
state.consumeTabStartupCommand,
|
||||
(paneKey) => state.sleepingAgentSessionsByPaneKey[paneKey],
|
||||
state.clearSleepingAgentSession
|
||||
)
|
||||
},
|
||||
// Why `startup` and not startupWithSetupSplitWait: setup-split hands the pane a copy, so only
|
||||
// the raw prop still matches the object the store holds. Read and consume run in one
|
||||
// synchronous step, so nothing can queue in between.
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('parked mirrored-pane resume replay', () => {
|
||||
key: 'session_id',
|
||||
id: 'codex-session-replay-1'
|
||||
})
|
||||
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
|
||||
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('replays without relaunching when the mirror reports the host PTY live', () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('remote sleeping-agent compatibility', () => {
|
||||
setRemoteSleepRecord(value)
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBeUndefined()
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBe(value)
|
||||
})
|
||||
|
||||
it('preserves legacy automatic wake when host authority is not known', () => {
|
||||
@@ -59,6 +59,6 @@ describe('remote sleeping-agent compatibility', () => {
|
||||
setRemoteSleepRecord(value)
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBeUndefined()
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[value.paneKey]).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,23 @@ function makeTerminalTab(id: string): Record<string, unknown> {
|
||||
}
|
||||
|
||||
describe('resumeSleepingAgentSessionsForWorktree replay protection', () => {
|
||||
it('keeps a sleeping record retryable when the first resume spawn never arrives', () => {
|
||||
const record = makeRecord()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
} as never)
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
|
||||
// A second activation can observe the failed/in-flight attempt, but must not
|
||||
// clear the record or launch a duplicate before the execution host answers.
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
|
||||
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('stores provider-session metadata in the queued startup and runtime claim', () => {
|
||||
const record = makeRecord()
|
||||
useAppStore.setState({
|
||||
@@ -47,6 +64,10 @@ describe('resumeSleepingAgentSessionsForWorktree replay protection', () => {
|
||||
expect(state.pendingStartupByTabId[resumedTab.id]?.resumeProviderSession).toEqual(
|
||||
record.providerSession
|
||||
)
|
||||
expect(state.pendingStartupByTabId[resumedTab.id]?.sleepingAgentResumeIdentity).toEqual({
|
||||
paneKey: record.paneKey,
|
||||
capturedAt: record.capturedAt
|
||||
})
|
||||
expect(state.automaticAgentResumeClaimsByTabId[resumedTab.id]).toEqual({
|
||||
worktreeId: record.worktreeId,
|
||||
launchAgent: record.agent,
|
||||
@@ -71,7 +92,7 @@ describe('resumeSleepingAgentSessionsForWorktree replay protection', () => {
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
|
||||
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('does not fork after startup is consumed but before hooks report live status', () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1')
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('resumes live-checkpoint records when no preserved pane can own recovery', () => {
|
||||
@@ -113,7 +113,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1')
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('resumes legacy sleep records without an origin when no preserved pane can own recovery', () => {
|
||||
@@ -130,7 +130,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
const resumedTab = (state.tabsByWorktree['wt-1'] ?? []).find((tab) => tab.id !== 'tab-1')
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('skips worktree-sleep records owned by a preserved stable UUID pane', () => {
|
||||
@@ -262,8 +262,8 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(launched).toBe(2)
|
||||
expect(resumedTabs).toHaveLength(2)
|
||||
expect(Object.keys(state.pendingStartupByTabId)).toHaveLength(2)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[initiallyUnowned.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[initiallyOwned.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[initiallyUnowned.paneKey]).toBeDefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[initiallyOwned.paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('skips active stable-pane records in an inactive split leaf of the active terminal tab', () => {
|
||||
@@ -368,7 +368,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(launched).toBe(1)
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('resumes live stable-pane records when the preserved leaf has no PTY to cold-restore', () => {
|
||||
@@ -394,7 +394,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(launched).toBe(1)
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('does not let a sibling split-pane PTY claim a live stable-pane record', () => {
|
||||
@@ -418,7 +418,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(launched).toBe(1)
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[resumedTab!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('skips hibernated stable panes after their live PTY binding is cleared', () => {
|
||||
@@ -491,8 +491,8 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
const state = useAppStore.getState()
|
||||
expect(launched).toBe(2)
|
||||
expect(state.tabsByWorktree['wt-1']).toHaveLength(3)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[first.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[second.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[first.paneKey]).toBeDefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[second.paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('resumes worktree-sleep records into a fresh tab', () => {
|
||||
@@ -510,7 +510,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0]?.launchAgent).toBe('claude')
|
||||
expect(state.pendingStartupByTabId[tabs[0]!.id]?.showSessionRestoredBanner).toBe(true)
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('uses captured launch config instead of changed settings when resuming worktree sleep', () => {
|
||||
@@ -579,7 +579,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
agentArgs: '--newer'
|
||||
})
|
||||
expect(state.sleepingAgentSessionsByPaneKey[first.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[duplicate.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[duplicate.paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('lets a preserved pane claim its provider session and clears only stale duplicates', () => {
|
||||
@@ -689,7 +689,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
agentArgs: '--active'
|
||||
})
|
||||
expect(state.sleepingAgentSessionsByPaneKey[completed.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[active.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[active.paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not let invalid pane-owned records block a valid duplicate resume', () => {
|
||||
@@ -724,7 +724,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
expect(launched).toBe(1)
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.sleepingAgentSessionsByPaneKey[invalid.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[valid.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[valid.paneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
it('fresh-resumes when explicit tabId disagrees with the stable pane-key tab id', () => {
|
||||
@@ -749,7 +749,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
)
|
||||
expect(launched).toBe(1)
|
||||
expect(resumedTab?.launchAgent).toBe('claude')
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('clears completed worktree-sleep records without preserved panes instead of resuming', () => {
|
||||
@@ -794,7 +794,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
|
||||
const state = useAppStore.getState()
|
||||
expect(launched).toBe(1)
|
||||
expect(state.tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude')
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
})
|
||||
|
||||
it('clears hydrated interrupted worktree-sleep records without launching a tab', () => {
|
||||
|
||||
@@ -132,6 +132,29 @@ function activeOrQueuedResumeClaimsProviderSession(
|
||||
return false
|
||||
}
|
||||
|
||||
function isSleepingResumeStartupInFlight(
|
||||
record: SleepingAgentSessionRecord,
|
||||
state: ReturnType<typeof useAppStore.getState>
|
||||
): boolean {
|
||||
const worktreeTabIds = new Set(
|
||||
(state.tabsByWorktree[record.worktreeId] ?? []).map((tab) => tab.id)
|
||||
)
|
||||
return Object.entries(state.pendingStartupByTabId).some(([tabId, startup]) => {
|
||||
const identity = startup.sleepingAgentResumeIdentity
|
||||
return (
|
||||
worktreeTabIds.has(tabId) &&
|
||||
identity?.paneKey === record.paneKey &&
|
||||
identity.capturedAt === record.capturedAt &&
|
||||
startup.launchAgent === record.agent &&
|
||||
agentProviderSessionsEqual(
|
||||
record.agent,
|
||||
startup.resumeProviderSession,
|
||||
record.providerSession
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: an interrupted turn is still resumable — `claude --resume` reopens the transcript at the
|
||||
// prompt — so discarding those records only stranded the session across wake and restart.
|
||||
function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean {
|
||||
@@ -225,6 +248,11 @@ export function resumeSleepingAgentSessionsForWorktree(
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isSleepingResumeStartupInFlight(record, currentState)) {
|
||||
// The queued startup is the retry path while the execution host has not
|
||||
// proved a fresh PTY; preserve the record for a later spawn or retry.
|
||||
continue
|
||||
}
|
||||
if (activeOrQueuedResumeClaimsProviderSession(record, currentState, isPaneOwned)) {
|
||||
// Why: main can replay the old wake record after the same provider
|
||||
// session was already queued in a fresh tab; clear the stale replay.
|
||||
|
||||
@@ -109,6 +109,10 @@ export function launchSleepingAgentSession(
|
||||
resumeProviderSession: record.providerSession,
|
||||
launchAgent: record.agent,
|
||||
...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}),
|
||||
sleepingAgentResumeIdentity: {
|
||||
paneKey: record.paneKey,
|
||||
capturedAt: record.capturedAt
|
||||
},
|
||||
...(startupPlan.startupCommandDelivery
|
||||
? { startupCommandDelivery: startupPlan.startupCommandDelivery }
|
||||
: {}),
|
||||
@@ -124,7 +128,6 @@ export function launchSleepingAgentSession(
|
||||
launchAgent: record.agent,
|
||||
providerSession: record.providerSession
|
||||
})
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
if (!options?.suppressNavigation) {
|
||||
state.setActiveTabType('terminal')
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('preserved-pane replacement contract on workspace activation', () => {
|
||||
const after = useAppStore.getState()
|
||||
const tabs = after.tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined()
|
||||
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
const replacement = tabs[0]!
|
||||
expect(after.automaticAgentResumeClaimsByTabId[replacement.id]?.providerSession).toEqual({
|
||||
key: 'session_id',
|
||||
|
||||
@@ -566,6 +566,8 @@ export type TerminalSlice = {
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
/** Stable identity of the sleeping record held until a fresh resume PTY spawns. */
|
||||
sleepingAgentResumeIdentity?: { paneKey: string; capturedAt: number }
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
/** Explicit CLI override for host-owned agent launches; omission uses host settings. */
|
||||
@@ -748,6 +750,8 @@ export type TerminalSlice = {
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
/** Stable identity of the sleeping record held until a fresh resume PTY spawns. */
|
||||
sleepingAgentResumeIdentity?: { paneKey: string; capturedAt: number }
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
agentArgsOverride?: string | null
|
||||
@@ -768,6 +772,8 @@ export type TerminalSlice = {
|
||||
envToDelete?: string[]
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
/** Stable identity of the sleeping record held until a fresh resume PTY spawns. */
|
||||
sleepingAgentResumeIdentity?: { paneKey: string; capturedAt: number }
|
||||
launchToken?: string
|
||||
launchAgent?: TuiAgent
|
||||
agentArgsOverride?: string | null
|
||||
|
||||
Reference in New Issue
Block a user