mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
* fix(native-chat): an answerable prompt card owns the chat input until its answer lands * fix(mobile): a terminal chat's composer waits while its prompt card is up * test(native-chat): type the prompt card fixtures without casts * fix(native-chat): scope replies to acknowledged prompt occurrences * fix(native-chat): preserve answer ordering and verified delivery * test(native-chat): keep mock RPC client inside test boundary * test(native-chat): place mock fixtures in the test-only scope * Keep runtime comments within the module size limit * test: preserve prompt delivery coverage in desktop CI * Treat an older host's accepted write as delivered A newer desktop or phone talking to a host that predates the write settlement field read every accepted reply as "unconfirmed". Prompt cards never dismissed, the phone showed "Response unconfirmed" on every tap and ordinary chat messages were held as "Delivery unconfirmed". The reader now uses writeSettlement when present and otherwise keeps the host's whole-write accepted/refused verdict, exactly as before this branch. Only prompt answers ask for provider settlement; ordinary callers (follow-up delivery, paste drafts, option commands, composer sends) are back on the original contract, so the legacy-handoff error class, its flag, the sequence-only send helper and the mobile handoff hook are gone. * Keep terminal-pane Escape on the plain accepted write Every pane's Escape/Ctrl+C goes through pty:writeAccepted. This branch had switched that IPC to wait for provider settlement, which dropped the "remount this pane" signal for a daemon session awaiting recovery and could stall later keystrokes behind a slow daemon acknowledgment. pty:writeAccepted is back to its original local-only, synchronous write. Prompt answers opt into settlement with requireWriteSettlement on the same channel, and a settled refusal while the daemon recovers now sends the same remount signal. Ordinary verified sends regain their original fallback write. * Report a partly accepted local paste as unconfirmed A settled local write split into chunks returned plain false when a later chunk was refused after earlier ones were accepted. Callers read false as "nothing was written", so chat showed "Message not sent" with a prefix already in the agent's input. It now reports the write as unconfirmed, the same verdict the paired host gives for a partial write. * Hide the chat composer under a prompt card instead of unmounting it When an approval or question card took the input region, the composer unmounted. A message still waiting for its Enter was cancelled and its bubble deleted after the draft had already been cleared, so the message vanished without a notice; composer history was also wiped each time. The composer now stays mounted but hidden while a card owns input, so its state survives. A send that has not submitted yet is still stopped (its Enter would answer the card), but its bubble stays with "Message not sent" so the text is not lost. The composer ref is detached while hidden, so root typing, paste and reveal focus never reach it. * Keep an answered prompt hidden after the chat view remounts The "answered" dismissal lived in component state. Toggling chat to terminal and back, a PTY reconnect, or leaving the phone session and coming back while the approved tool was still running brought the answered approval back, and it then took over the input again. Desktop now keeps the answered occurrence per pane outside the view; phone keeps it per chat tab outside the controller. Both still retire it when the pane observes the prompt clear or change, desktop also when the tab retires, and both maps are size-bounded. * Update the prompt-reply reliability gate for the review fixes Older hosts' accepted answers now dismiss like acknowledged ones, the composer stays mounted under a card, and answered prompts survive a view remount. The gate's invariant, oracle, assertion list, new test files and the two latest evidence runs now describe that contract. * Let users hide a prompt card, keep Escape from denying, and gate only Send on the phone The chat input could stay locked behind a card the host never closes (for example after a Deny typed in the terminal), and Escape on a focused approval card denied the tool even when the user meant to close a picker. - A Hide control (chevron) on terminal approval and question cards, desktop and phone, hides that prompt occurrence and gives the input back. It writes nothing to the agent and uses the same per-occurrence dismissal as an acknowledged answer, so a new occurrence shows the card again. - On desktop, Escape on a card now does the same Hide instead of Deny, and a card that appears while the user is typing no longer takes focus. - On the phone, a card blocks only Send: typing, dictation and image attach keep working on the draft. The placeholder is back to the normal one. * Fix two comments that still called older-host replies unconfirmed Since an older host's accepted write now counts as delivered, the requireWriteSettlement comment and the reliability gate's oracle said the opposite of the code. Both now describe the current rule. * Collapse prompt cards to a strip instead of hiding them, and close the round-2 gaps Hide removed a card completely, so nothing on screen said a prompt was still waiting, and several edges let the chat type into a live prompt. - Collapse (the header chevron, or Escape on desktop) folds the card to a one-line strip above the composer; the strip's chevron expands it back. Collapsing writes nothing, frees the composer, and is disabled while an answer is still being written. Each pane or tab keeps the occurrence as answered or collapsed, so a remount restores the same view. - Questions now carry the host wait's start like approvals, so an identical question in a new wait shows again (desktop and phone). A transcript-only prompt, which has no wait start, is dropped when the view stops observing it, and a transcript still loading no longer clears a dismissal. - Desktop: while a card owns the input, the hidden composer cannot send or interrupt even if it still has keyboard focus, and the card takes focus in the same commit. A send the card retires no longer types Ctrl+U under it. - Phone: an Ask hides the heuristic card read from the same waiting status, and the dismissal store is scoped by host, worktree and tab. * Keep a collapsed card's partial answer, and scope its focus to its own pane Collapsing a question card unmounted it, so expanding it again lost the chosen step, selections and typed "Other" text; Escape typed in that text field collapsed the card. A card arriving while the user typed in another surface (sidebar, notes, a browser URL bar) also took the keyboard. - The collapsed card now stays mounted but hidden (and inert on desktop) under its strip, on desktop and phone, so a partial answer survives collapse and expand. Escape inside the card's text field no longer collapses it. The question card shows the same focus ring as the approval card. - A card takes focus only from inside its own pane (its hidden composer) or from the page body, never from a text field elsewhere. - Desktop and phone share one dismissal store in src/shared, bounded by the existing scope-cache helper, which moves to src/shared with it. - The card send imports the verified helper from its own module, and the phone files are split so each name matches its contents (header action, strip, lane selector). * Return focus to the composer after a prompt card collapses Since a collapsed card stays mounted, Escape or the chevron left keyboard focus inside the now hidden, inert card. The composer's reveal-focus took that as focus already in the pane and stood down, then the browser dropped focus to the page body, so typed keys went nowhere. Reveal-focus now treats focus inside a hidden or inert subtree as not in the pane and focuses the composer. On the phone, collapsing a card also dismisses the keyboard so a hidden reply field does not keep it. * Keep the question card's collapse chevron beside its Cancel button The question card header spread its three items with justify-between, which put the new chevron in the middle of the header. The title now takes the free space, as in the approval card, so the chevron sits next to Cancel at the right edge. * Run the prompt tests on the merged main Main now runs Vitest under Bun, which resolves a long data: URL import as a package name, so the SSH delivery test loads its bundled mobile module from a temp file instead. The phone prompt harnesses mock the live line that main's view now renders, and add Platform, which main's text-selection helper reads, the same way main's own view tests do.
399 lines
17 KiB
TypeScript
399 lines
17 KiB
TypeScript
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
|
import { randomUUID } from 'node:crypto'
|
|
import { preserveTerminalRetirementProofs } from './mobile-session-terminal-retirement-proof'
|
|
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
|
import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement'
|
|
import type { TuiAgent } from '../../shared/tui-agent'
|
|
import { isTuiAgent } from '../../shared/tui-agent-config'
|
|
import type { RuntimeStore } from './runtime-store-contract'
|
|
import type { RuntimeClientSettingsController } from './runtime-client-settings'
|
|
import type { RuntimeAutomationController } from './runtime-automation-controller'
|
|
import { RuntimeArtifactController } from './runtime-artifact-controller'
|
|
import type { OrchestrationEnvironmentTransport } from './orchestration/environment-transport'
|
|
import type { RuntimeOrchestrationFederation } from './runtime-orchestration-federation'
|
|
import type {
|
|
RuntimeGraphStatus,
|
|
RuntimeMobileSessionCreateTerminalResult,
|
|
RuntimeMobileSessionTabsResult,
|
|
RuntimeMobileSessionTabsSnapshot,
|
|
RuntimeSyncedTab,
|
|
RuntimeTerminalFocus,
|
|
RuntimeTerminalResolvePane,
|
|
RuntimeWorktreeTerminalSleepResult
|
|
} from '../../shared/runtime-types'
|
|
import {
|
|
RUNTIME_GRAPH_RELOAD_TIMEOUT_MS,
|
|
RuntimeGraphReloadLifecycle
|
|
} from './runtime-graph-reload-lifecycle'
|
|
import { ClientHostedPageReconciliationWindow } from './client-hosted-page-reconciliation-window'
|
|
import { ClientSessionTabSelectionStore } from './client-session-tab-selection'
|
|
import { WorktreeTerminalMutationLock } from './worktree-terminal-mutation-lock'
|
|
import { RemoteRuntimeTerminalCreateIdempotency } from './remote-runtime-terminal-create-idempotency'
|
|
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
|
import type { MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer'
|
|
import { createMobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer'
|
|
import type { MobileSessionTabsAgentStatusHeartbeat } from './mobile-session-tabs-agent-status-heartbeat'
|
|
import { createMobileSessionTabsAgentStatusHeartbeat } from './mobile-session-tabs-agent-status-heartbeat'
|
|
import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer'
|
|
import type {
|
|
PtyControllerInventory,
|
|
RuntimePtyController
|
|
} from './runtime-pty-controller-contract'
|
|
import type { RuntimeLeafRecord } from './runtime-terminal-state-records'
|
|
import type { TerminalHandleRecord } from './runtime-terminal-contracts'
|
|
import type { PtyIncarnationHandleRecord } from './orca-runtime-core'
|
|
import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoint-scheduler'
|
|
import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry'
|
|
import { RuntimeTerminalWriter } from './runtime-terminal-writer'
|
|
import { writeRefused } from '../../shared/pty-write-settlement'
|
|
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
|
|
import { TerminalIntentionalStops } from './terminal-intentional-stops'
|
|
import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts'
|
|
import type { TuiIdleEvidenceSource } from './tui-idle-evidence-source'
|
|
import { hasTerminalCommandPainted } from './terminal-command-paint'
|
|
import {
|
|
TUI_IDLE_DEFAULT_TIMEOUT_MS,
|
|
TUI_IDLE_POLL_INTERVAL_MS,
|
|
TUI_IDLE_QUIESCENCE_MS
|
|
} from './orca-runtime-postlude'
|
|
import { RuntimeTerminalWait as RuntimeTerminalWaitController } from './runtime-terminal-wait'
|
|
import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict'
|
|
|
|
export class OrcaRuntimeWithRuntimeId {
|
|
protected readonly runtimeId = randomUUID()
|
|
|
|
protected readonly startedAt = Date.now()
|
|
|
|
protected readonly store: RuntimeStore | null
|
|
|
|
protected readonly clientSettings: RuntimeClientSettingsController
|
|
|
|
protected readonly automation: RuntimeAutomationController
|
|
|
|
protected readonly artifacts = new RuntimeArtifactController()
|
|
|
|
protected readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null
|
|
|
|
protected readonly orchestrationFederation: RuntimeOrchestrationFederation
|
|
|
|
protected rendererGraphEpoch = 0
|
|
|
|
protected graphStatus: RuntimeGraphStatus = 'unavailable'
|
|
|
|
protected authoritativeWindowId: number | null = null
|
|
|
|
protected headlessGraphFallbackAvailable = false
|
|
|
|
protected pendingHeadlessPromotionWindowId: number | null = null
|
|
|
|
protected rendererGeneration: string | null = null
|
|
|
|
protected readonly graphReloadLifecycle = new RuntimeGraphReloadLifecycle({
|
|
timeoutMs: RUNTIME_GRAPH_RELOAD_TIMEOUT_MS,
|
|
onSettled: ({ revision, windowId, outcome, durationMs }) => {
|
|
console.info(
|
|
`[runtime-graph] reload revision=${revision} window=${windowId} outcome=${outcome} durationMs=${durationMs}`
|
|
)
|
|
},
|
|
onTimeout: (_revision, windowId) => this.handleGraphReloadTimeout(windowId)
|
|
})
|
|
|
|
protected tabs = new Map<string, RuntimeSyncedTab>()
|
|
|
|
protected mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
|
|
|
|
/** Single host writer for mobile session snapshots; versions are total-order stamps. */
|
|
protected storeMobileSessionSnapshot(
|
|
worktreeId: string,
|
|
snapshot: RuntimeMobileSessionTabsSnapshot
|
|
): RuntimeMobileSessionTabsSnapshot {
|
|
for (const replacement of getStructuredAgentSessionHost()?.conversationReplacements?.() ?? []) {
|
|
snapshot = replaceConversationInSnapshot(snapshot, replacement)
|
|
}
|
|
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
|
snapshot = preserveTerminalRetirementProofs(snapshot, existing)
|
|
const snapshotVersion = existing
|
|
? Math.max(snapshot.snapshotVersion, existing.snapshotVersion + 1)
|
|
: snapshot.snapshotVersion
|
|
const stamped =
|
|
snapshotVersion === snapshot.snapshotVersion ? snapshot : { ...snapshot, snapshotVersion }
|
|
this.mobileSessionTabsByWorktree.set(worktreeId, stamped)
|
|
return stamped
|
|
}
|
|
|
|
protected structuredAgentSessionTabRestorePromise: Promise<void> | null = null
|
|
|
|
// Whether the last tab restore ran with chats on disk but no host to list them.
|
|
protected structuredAgentSessionInventoryUnverifiable = false
|
|
|
|
protected structuredAgentSessionStartupRestorePromise: Promise<void> | null = null
|
|
|
|
protected mobileSessionTabsChangeSequence = 0
|
|
|
|
protected sessionTabsInventoryPublicationEpoch: number | null = null
|
|
|
|
protected sessionTabsInventoryWaiters = new Set<() => void>()
|
|
|
|
// Worktrees answered with the unpublished placeholder, owed their real answer once the graph publishes.
|
|
protected worktreesAwaitingSessionTabsPublication = new Set<string>()
|
|
|
|
protected readonly clientHostedPageReconciliation = new ClientHostedPageReconciliationWindow(
|
|
Date.now()
|
|
)
|
|
|
|
// Main-local touches advance stored versions; reject unchanged renderer resends using
|
|
// the renderer's last accepted epoch/version instead.
|
|
protected acceptedRendererMobileSnapshotByWorktree = new Map<
|
|
string,
|
|
{
|
|
publicationEpoch: string
|
|
rendererVersion: number
|
|
rendererTabCount: number
|
|
rendererTabIdentityKeys: ReadonlySet<string>
|
|
}
|
|
>()
|
|
|
|
// Why: worktree ids are path-derived and get recreated, so a renderer frame
|
|
// that raced the delete must be rejected by the removed occupant's identity.
|
|
// Entries are cleared once a snapshot carrying the successor's instanceId
|
|
// is accepted; identity-less frames are fenced by renderer generation.
|
|
protected readonly removedMobileSessionWorktreeIds = new Map<
|
|
string,
|
|
{
|
|
removedPublicationEpoch?: string
|
|
// Why: a rejected frame is still "published" on the renderer side, so a
|
|
// later unchanged-list mention must not spiral into resync requests.
|
|
rejectedPublication?: boolean
|
|
}
|
|
>()
|
|
|
|
protected clientSessionTabSelections = new ClientSessionTabSelectionStore()
|
|
|
|
// Why: idempotency map for mobile terminal creation — a retried create with the
|
|
// same clientMutationId returns the in-flight operation instead of duplicating.
|
|
protected mobileTerminalCreateByMutationId = new Map<
|
|
string,
|
|
Promise<RuntimeMobileSessionCreateTerminalResult>
|
|
>()
|
|
|
|
protected readonly terminalCreateIdempotency = new RemoteRuntimeTerminalCreateIdempotency()
|
|
|
|
// Why: concurrent clients sleeping one host workspace must share one physical teardown.
|
|
protected terminalSleepByWorktreeId = new Map<
|
|
string,
|
|
Promise<RuntimeWorktreeTerminalSleepResult>
|
|
>()
|
|
|
|
protected readonly terminalMutationLock = new WorktreeTerminalMutationLock()
|
|
|
|
protected terminalSleepStateByWorktreeId = new Map<
|
|
string,
|
|
{
|
|
worktreeId: string
|
|
generation: number
|
|
phase: 'stopping' | 'partial' | 'sleeping'
|
|
ptyIds: string[]
|
|
terminalHandles: string[]
|
|
terminalHandlesByPtyId: Record<string, string[]>
|
|
}
|
|
>()
|
|
|
|
protected terminalSleepGeneration = 0
|
|
|
|
protected terminalPaneRecoveryByIdentity = new Map<string, Promise<RuntimeTerminalResolvePane>>()
|
|
|
|
// Why: idempotency map for worktree.create — a create interrupted by a mobile
|
|
// connection migration is retried with the same clientMutationId and returns
|
|
// the in-flight (or just-finished) operation instead of a duplicate worktree.
|
|
protected worktreeCreateByMutationId = new Map<string, Promise<unknown>>()
|
|
|
|
// Why: a mobile create waits for the renderer to publish the new tab's surface
|
|
// via graph-sync, but a throttled/hidden renderer can park that past the surface
|
|
// timeout and the create would then destroy the live PTY (#7587). This lets the
|
|
// renderer's own PTY spawn publish the surface main-side, scoped to in-flight
|
|
// creates so ordinary renderer spawns never publish here.
|
|
protected pendingMobileTerminalCreatesByKey = new Map<
|
|
string,
|
|
{
|
|
activate: boolean
|
|
paired: boolean
|
|
selectIfNoActiveTab: boolean
|
|
viewMode?: 'terminal' | 'chat'
|
|
/** Resolved agent launch command, kept so a settle over a bare renderer
|
|
* PTY can still deliver the launch instead of succeeding silently (STA-3214). */
|
|
startupCommand?: string
|
|
}
|
|
>()
|
|
|
|
protected mobileSessionTabListeners = new Set<{
|
|
listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void
|
|
clientNavigationId?: string
|
|
}>()
|
|
|
|
protected pendingMobileSessionTabsChangeSequenceByWorktree = new Map<string, number>()
|
|
|
|
// Why: one watermark per repo replaces per-closed-pane fences while preserving stale-write safety.
|
|
protected terminalTopologyRevisionByRepoId = new Map<string, number>()
|
|
|
|
// Why: provider exit can beat surface registration; that exact dead incarnation must never publish.
|
|
protected earlyExitedPtyIncarnations = new Map<string, PtyIncarnationId | null>()
|
|
|
|
protected pendingPtyRegistrationIncarnations = new Map<string, PtyIncarnationId | null>()
|
|
|
|
// Why public: the PTY IPC layer's stop paths write it and its exit delivery reads it.
|
|
readonly intentionalPtyStops = new TerminalIntentionalStops()
|
|
|
|
readonly terminalRunFacts = new TerminalRunFactsRegister()
|
|
|
|
/** Both spawn-commit funnels report each committed process here, once. */
|
|
noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void {
|
|
this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding)
|
|
this.intentionalPtyStops.noteSpawnCommit(commit.id)
|
|
}
|
|
|
|
// Coalesce title/status notifications and emit the latest session snapshot.
|
|
protected readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer =
|
|
createMobileSessionTabsNotifyCoalescer((worktreeId) =>
|
|
this.flushScheduledMobileSessionTabsChanged(worktreeId)
|
|
)
|
|
|
|
protected readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat =
|
|
createMobileSessionTabsAgentStatusHeartbeat(
|
|
(ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId),
|
|
(worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId)
|
|
)
|
|
|
|
// Concurrent focus requests share one host reveal; the latest pane wins.
|
|
protected readonly terminalFocusNavigationCoalescer =
|
|
new TerminalFocusNavigationCoalescer<RuntimeTerminalFocus>()
|
|
|
|
protected pendingMobileSessionPtyAggregateInventoryRefresh: Promise<PtyControllerInventory | null> | null =
|
|
null
|
|
|
|
/** The agent Orca believes owns this pane, for tui-idle evidence ranking. Launch
|
|
* authority first; the live foreground agent covers panes Orca did not launch. */
|
|
protected getPaneAgentForTuiIdle(ptyId: string | null | undefined): TuiAgent | null {
|
|
if (!ptyId) {
|
|
return null
|
|
}
|
|
const pty = this.ptysById.get(ptyId)
|
|
const agent = pty?.launchAgent ?? pty?.foregroundAgent ?? null
|
|
return isTuiAgent(agent) ? agent : null
|
|
}
|
|
|
|
/** One-shot delivery retries, keyed by leaf. See checkDeliverySettledAndArmRecheck. */
|
|
protected deliveryRecheckTimersByLeafKey = new Map<string, ReturnType<typeof setTimeout>>()
|
|
|
|
// Why: counts authoritative graph statements so a PTY's recorded surface can be told apart
|
|
// from one the graph has simply not published yet (pty-recorded-surface-topology.ts).
|
|
protected graphSequence = 0
|
|
|
|
protected leaves = new Map<string, RuntimeLeafRecord>()
|
|
|
|
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
|
|
// ptyId keeps active TUI redraws independent of the total open terminal count.
|
|
protected leavesByPtyId = new Map<string, RuntimeLeafRecord[]>()
|
|
|
|
protected handles = new Map<string, TerminalHandleRecord>()
|
|
|
|
protected handleByLeafKey = new Map<string, string>()
|
|
|
|
protected handleByPtyId = new Map<string, string>()
|
|
|
|
protected handleByPtyIncarnation = new Map<string, PtyIncarnationHandleRecord>()
|
|
|
|
// A provider announces a replacement before the spawn commit can bind its
|
|
// pane. Keep the predecessor aliases fenced during that hand-off window.
|
|
protected pendingPtyHandleReplacementFences = new Map<
|
|
string,
|
|
{
|
|
incarnationId: PtyIncarnationId
|
|
staleHandles: Set<string>
|
|
pendingRegistration: boolean
|
|
}
|
|
>()
|
|
|
|
// Why: pointer state is process-local; one harmless replay after restart avoids a wire or schema change.
|
|
protected readonly lastPointedMessageSequenceByHandle = new Map<string, number>()
|
|
|
|
// Why: a waiter can reserve an older row while a newer row advances the sequence watermark.
|
|
protected readonly pointedMessageIdsByHandle = new Map<string, Set<string>>()
|
|
|
|
protected readonly mailPointerRepointScheduler = new MailPointerRepointScheduler((handle) =>
|
|
this.repointPendingMessagesForHandle(handle)
|
|
)
|
|
|
|
protected syntheticTerminalHandles = new Set<string>()
|
|
|
|
protected detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
|
|
|
|
protected graphSyncCallbacks: (() => void)[] = []
|
|
|
|
protected readonly terminalWaiters = new RuntimeTerminalWaiterRegistry()
|
|
|
|
protected readonly terminalWriter = new RuntimeTerminalWriter(
|
|
(ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false,
|
|
(ptyId) => this.getPtyWriteHostPlatform(ptyId),
|
|
(ptyId) => this.getPtyAgent(ptyId),
|
|
(ptyId, data, inputKind) =>
|
|
this.ptyController?.writeWithSettlement?.(ptyId, data, inputKind) ??
|
|
writeRefused('provider_cannot_settle')
|
|
)
|
|
|
|
// Why one source: every tui-idle site must read the same evidence, or they rank one pane differently.
|
|
protected readonly tuiIdleEvidenceSource: TuiIdleEvidenceSource = {
|
|
quiescenceMs: TUI_IDLE_QUIESCENCE_MS,
|
|
getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null,
|
|
getAdoptedPtyIdleStatus: (pty) => this.getAdoptedPtyExplicitIdleStatus(pty),
|
|
getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId),
|
|
getFirstPartyAgentStatus: (ptyId) =>
|
|
(ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null,
|
|
getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent),
|
|
readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId),
|
|
readRuledScreen: (ptyId) => this.readRuledScreen(ptyId),
|
|
getTitleObservedAtEpochMs: (ptyId) =>
|
|
(ptyId ? this.ptysById.get(ptyId)?.lastOscTitleEpochMs : null) ?? null
|
|
}
|
|
|
|
protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({
|
|
...this.tuiIdleEvidenceSource,
|
|
intervalMs: TUI_IDLE_POLL_INTERVAL_MS,
|
|
getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null,
|
|
hasCommandPainted: (ptyId) => {
|
|
const pty = this.ptysById.get(ptyId)
|
|
return pty === undefined || hasTerminalCommandPainted(pty)
|
|
},
|
|
// Why the runtime's own emulator: a provider snapshot would be a host round trip per tick.
|
|
readVisibleScreen: (ptyId) =>
|
|
this.headlessTerminals.has(ptyId)
|
|
? this.readHeadlessVisibleTerminalState(ptyId).then(
|
|
(screen) => screen?.lines.join('\n') ?? null
|
|
)
|
|
: null,
|
|
getLiveLeaf: (leaf) => this.leaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) ?? leaf,
|
|
resolve: (waiter, result) => this.terminalWaiters.resolve(waiter, result)
|
|
})
|
|
|
|
protected readonly terminalWait = new RuntimeTerminalWaitController(
|
|
{
|
|
...this.tuiIdleEvidenceSource,
|
|
defaultTimeoutMs: TUI_IDLE_DEFAULT_TIMEOUT_MS,
|
|
getLivePty: (handle) => this.getLivePtyForHandle(handle),
|
|
getLiveLeaf: (handle) => this.getLiveLeafForHandle(handle),
|
|
startVisibleReadProbe: (waiter, waiterTimeoutMs, agent) =>
|
|
this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs, agent)
|
|
},
|
|
this.terminalWaiters,
|
|
this.terminalIdlePolls
|
|
)
|
|
|
|
protected ptyController: RuntimePtyController | null = null
|
|
|
|
protected readonly ptyLivenessVerdictByPtyId = new Map<
|
|
string,
|
|
{ verdict: PtyLivenessVerdict; observedAt: number }
|
|
>()
|
|
|
|
protected ptyLivenessObservationSequence = 0
|
|
}
|