diff --git a/src/main/index.ts b/src/main/index.ts index a142fc6cf7d..55d0eb6ddeb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -79,6 +79,10 @@ import { registerCoreHandlers } from './ipc/register-core-handlers/register-core import { initObservability, shutdownObservability } from './observability' import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client' +import { + createDesktopPaneAgentIdentityCensus, + createHeadlessPaneAgentIdentityCensus +} from './telemetry/pane-agent-identity-census-composition' import { classifyError } from './telemetry/classify-error' import { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry' import { @@ -2490,6 +2494,9 @@ void app.whenReady().then(async () => { runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) } }) + const unsubscribePaneAgentIdentityHook = agentHookServer.subscribeEnrichedStatus((enriched) => { + runtime?.observeAgentHookStatus(enriched) + }) // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land // here. Without it the live state published above becomes a zombie question card. const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { @@ -2506,6 +2513,7 @@ void app.whenReady().then(async () => { unsubscribeStatusChanges() unsubscribeProviderSessionChanges() unsubscribeHookStatusSessionTabs() + unsubscribePaneAgentIdentityHook() unsubscribeHookStatusClear() } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. @@ -2723,7 +2731,11 @@ void app.whenReady().then(async () => { envelope ) } + const paneAgentIdentityCensus = isServeMode + ? createHeadlessPaneAgentIdentityCensus() + : createDesktopPaneAgentIdentityCensus() const runtimeService = new OrcaRuntimeService(store, stats, { + paneAgentIdentityCensus, agentSessionClaimSigner: loadAgentSessionClaimSigner( getProfileUserDataPath(), getProfileUserDataPath() @@ -3508,6 +3520,7 @@ app.on('will-quit', (e) => { pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() + runtime?.shutdownPaneAgentIdentityCensus() // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a // bare script path that cannot express the guard and would otherwise keep spawning after quit. // Why bounded here: every other teardown member carries its own ceiling, and this one reaches diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 09e6fa8b8c8..182a9dddba5 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -78,6 +78,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + if (accepted) { + runtime?.observeAcceptedPtyWrite?.(args.id, args.data) + } + return accepted + }) } catch { return false } @@ -188,7 +200,19 @@ export function createPtyWriteInput(deps: { if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + const result = writePtyProviderInput(provider, args.id, args.data) + if (typeof result === 'boolean') { + if (result) { + runtime?.observeAcceptedPtyWrite?.(args.id, args.data) + } + return result + } + return result.then((accepted) => { + if (accepted) { + runtime?.observeAcceptedPtyWrite?.(args.id, args.data) + } + return accepted + }) } catch { return false } diff --git a/src/main/ipc/pty/pane/launch-authority.test.ts b/src/main/ipc/pty/pane/launch-authority.test.ts new file mode 100644 index 00000000000..e104022fad9 --- /dev/null +++ b/src/main/ipc/pty/pane/launch-authority.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { admitRendererAgentLaunchAuthority } from './launch-authority' + +function admit(resumeProviderSession: unknown) { + return admitRendererAgentLaunchAuthority({ + launchToken: 'token', + spawnEnv: { ORCA_AGENT_LAUNCH_TOKEN: 'token' }, + launchAgent: 'codex', + launchConfig: {} as never, + resumeProviderSession, + isReattach: false, + hasStablePaneOwner: false, + incarnationId: 'incarnation-1' + }) +} + +describe('renderer agent launch authority', () => { + it('classifies fresh launches and provider-session resumes distinctly', () => { + expect(admit(undefined)).toMatchObject({ launchMode: 'orca-launch' }) + expect(admit('provider-session')).toMatchObject({ launchMode: 'resume' }) + }) +}) diff --git a/src/main/ipc/pty/pane/launch-authority.ts b/src/main/ipc/pty/pane/launch-authority.ts index 93f3061f0b3..29850ac9ebf 100644 --- a/src/main/ipc/pty/pane/launch-authority.ts +++ b/src/main/ipc/pty/pane/launch-authority.ts @@ -10,10 +10,15 @@ export function admitRendererAgentLaunchAuthority(args: { spawnEnv: Record | undefined launchAgent: unknown launchConfig: SleepingAgentLaunchConfig | undefined + resumeProviderSession: unknown isReattach: boolean hasStablePaneOwner: boolean incarnationId: unknown -}): { launchToken: string; launchAgent: TuiAgent } | null { +}): { + launchToken: string + launchAgent: TuiAgent + launchMode: 'orca-launch' | 'resume' +} | null { if ( args.isReattach || args.hasStablePaneOwner || @@ -27,7 +32,11 @@ export function admitRendererAgentLaunchAuthority(args: { ) { return null } - return { launchToken: args.launchToken, launchAgent: args.launchAgent } + return { + launchToken: args.launchToken, + launchAgent: args.launchAgent, + launchMode: args.resumeProviderSession ? 'resume' : 'orca-launch' + } } export function shouldRefreshNativeClaudeAgentTeamsEnv(args: { diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 5648cca0219..04fc889f27a 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -458,7 +458,12 @@ describe('registerCoreHandlers', () => { it('passes the store through to handler registrars that need it', async () => { const store = { marker: 'store' } - const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null } + const paneAgentIdentityCensus = { marker: 'paneAgentIdentityCensus' } + const runtime = { + marker: 'runtime', + getAgentBrowserBridge: () => null, + getPaneAgentIdentityCensus: () => paneAgentIdentityCensus + } const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } @@ -539,7 +544,7 @@ describe('registerCoreHandlers', () => { expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store) expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store) expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store) - expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store) + expect(registerTelemetryHandlersMock).toHaveBeenCalledWith(store, paneAgentIdentityCensus) expect(registerOrcaProfileHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerSessionHandlersMock).toHaveBeenCalledWith(store) expect(registerUIHandlersMock).toHaveBeenCalledWith(store, { diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index a18fbb9a1d3..388eded3fb9 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -189,7 +189,7 @@ export function registerCoreHandlers( if (pluginService) { registerPluginHandlers(store, pluginService, runtime, marketplaceServices) } - registerTelemetryHandlers(store) + registerTelemetryHandlers(store, runtime.getPaneAgentIdentityCensus()) registerOrcaProfileHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch, onAuthMutation: lifecycleOptions.onOrcaProfileAuthMutation, diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 9b28270551a..93d844670f2 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -40,6 +40,7 @@ vi.mock('../telemetry/onboarding-cohort-classifier', () => ({ })) import { _resetStoreForTests, registerTelemetryHandlers } from './telemetry' +import { PaneAgentIdentityCensus } from '../telemetry/pane-agent-identity-census' function captureHandlers(): void { handlers.clear() @@ -71,7 +72,7 @@ function makeFakeStore(telemetry: GlobalSettings['telemetry']): { function registerWith(telemetry: GlobalSettings['telemetry']): FakeStoreState { const { store, state } = makeFakeStore(telemetry) - registerTelemetryHandlers(store) + registerTelemetryHandlers(store, new PaneAgentIdentityCensus({ emit: null })) captureHandlers() return state } @@ -161,10 +162,38 @@ describe('telemetry IPC handlers', () => { bucket_source: 'crossed_now' }) handler({}, 'daemon_audit_eligibility', {}) + handler({}, 'pane_agent_identity_availability', { rows: [] }) expect(trackMock).not.toHaveBeenCalled() expect(getCohortAtEmitMock).not.toHaveBeenCalled() }) + it('accepts identity availability only through the dedicated validated ingestion path', () => { + const census = new PaneAgentIdentityCensus({ emit: null }) + const ingest = vi.spyOn(census, 'ingestRelaySnapshot') + const { store } = makeFakeStore({ + installId: 'x', + existedBeforeTelemetryRelease: false, + optedIn: true + }) + registerTelemetryHandlers(store, census) + captureHandlers() + + handlers.get('telemetry:ingestPaneAgentIdentityAvailability')!( + {}, + { + environmentKey: 'relay-1', + snapshot: { epoch: 'epoch-1', revision: 1, rows: [] } + } + ) + + expect(ingest).toHaveBeenCalledWith('relay-1', { + epoch: 'epoch-1', + revision: 1, + rows: [] + }) + expect(trackMock).not.toHaveBeenCalled() + }) + it('injects cohort for setup script prompt events', () => { registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index c1fe7d0f68d..062dc78787f 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -18,6 +18,9 @@ import { resolveConsent, type ConsentState } from '../telemetry/consent' import type { Store } from '../persistence' import { isCohortExtendedEvent, isOnboardingEvent } from '../../shared/telemetry-events' import type { EventName, EventProps, OptInVia } from '../../shared/telemetry-events' +import type { PaneAgentIdentityCensus } from '../telemetry/pane-agent-identity-census' +import type { PaneAgentIdentityAvailabilitySnapshot } from '../../shared/pane-agent-identity-availability' +import { isPaneAgentIdentityAvailabilitySnapshot } from '../../shared/pane-agent-identity-availability-validator' // Module-level store ref: handlers need a synchronous `settings.telemetry` read to derive `via` before any mutation. let storeRef: Store | null = null @@ -25,6 +28,7 @@ let storeRef: Store | null = null const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ 'app_starred_orca', 'daemon_audit_eligibility', + 'pane_agent_identity_availability', 'star_nag_outcome', 'feature_interaction_usage_bucket_reached' ]) @@ -49,7 +53,10 @@ function deriveOptInVia(store: Store, incomingOptedIn: boolean): OptInVia { return 'settings' } -export function registerTelemetryHandlers(store: Store): void { +export function registerTelemetryHandlers( + store: Store, + paneIdentityCensus: PaneAgentIdentityCensus +): void { storeRef = store ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => { @@ -78,6 +85,34 @@ export function registerTelemetryHandlers(store: Store): void { track(eventName, finalProps as EventProps) }) + ipcMain.handle( + 'telemetry:ingestPaneAgentIdentityAvailability', + (_event, payload: unknown): void => { + const environmentKey = + payload && typeof payload === 'object' + ? (payload as { environmentKey?: unknown }).environmentKey + : undefined + const snapshot = + payload && typeof payload === 'object' + ? (payload as { snapshot?: unknown }).snapshot + : undefined + if ( + typeof environmentKey !== 'string' || + environmentKey.length === 0 || + environmentKey.length > 256 + ) { + return + } + if (snapshot !== undefined && !isPaneAgentIdentityAvailabilitySnapshot(snapshot)) { + return + } + paneIdentityCensus.ingestRelaySnapshot( + environmentKey, + snapshot as PaneAgentIdentityAvailabilitySnapshot | undefined + ) + } + ) + ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): Promise | void => { // Strict input typing — renderer can pass anything over IPC. if (typeof optedIn !== 'boolean') { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 4af2a87c45d..c79f51c8a7a 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -140,6 +140,8 @@ async function startOrcadRuntime( const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint') const { ServeReadinessPublisher } = await import('../server/serve-readiness') const { Store } = await import('../persistence/loading-store/store') + const { createHeadlessPaneAgentIdentityCensus } = + await import('../telemetry/pane-agent-identity-census-composition') const { ensureActiveOrcaProfile, initOrcaProfilePaths } = await import('../orca-profiles/profile-index-store') const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store') @@ -165,7 +167,9 @@ async function startOrcadRuntime( // registerPtyHandlers so the IPC layer routes through the daemon from the first call. await startOrcadDaemon() + const paneAgentIdentityCensus = createHeadlessPaneAgentIdentityCensus() const runtime = new OrcaRuntimeService(store, undefined, { + paneAgentIdentityCensus, // Why lazy: a daemon swap replaces the provider after construction, so an eager // reference would freeze the pre-daemon one. getLocalProvider: () => getLocalPtyProvider(), @@ -265,6 +269,9 @@ async function startOrcadRuntime( try { await rpc.stop() } finally { + // Why the census first: it reads runtime state, so it must settle before the daemon + // link goes away. + runtime.shutdownPaneAgentIdentityCensus(false) // Why disconnect and not shut down: the daemon must outlive this process, or an // orcad restart goes back to killing every running terminal. See // orcad-daemon-supervision.ts. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b25b3cef46..ba1c68ed62c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -344,7 +344,6 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../shared/terminal-tab-types' -import type { TuiAgent } from '../../shared/tui-agent' import type { BranchPrefixStrategy } from '../../shared/ui-chrome-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { WorkspaceSource as WorkspaceCreateTelemetrySource } from '../../shared/workspace-source' @@ -449,6 +448,19 @@ import type { LinearStatusSetResult } from '../../shared/linear/agent-access' import { runtimeTerminalDegradation } from './native-terminal-availability' +import { + MAX_PENDING_IDENTITY_OBSERVATIONS, + TITLE_CANDIDATE_WINDOW_MS +} from '../../shared/pane-agent-identity-availability' +import type { + PaneAgentIdentityAvailability, + PaneAgentIdentityHostKind +} from '../../shared/pane-agent-identity-evidence' +import { ManualAgentCommandTracker } from '../../shared/manual-agent-command-tracker' +import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../shared/tui-agent' +import { PaneAgentIdentityCensus } from '../telemetry/pane-agent-identity-census' +import { PaneAgentIdentityObservationAuthority } from './pane-agent-identity-observation-authority' import { BROWSER_UNAVAILABLE_ERROR_CODE, browserUnavailableMessage, @@ -3130,6 +3142,21 @@ type ProviderSnapshotReadOptions = { export class OrcaRuntimeService { private readonly runtimeId = randomUUID() + private readonly paneAgentIdentityCensus: PaneAgentIdentityCensus + private readonly paneAgentIdentityObservations: PaneAgentIdentityObservationAuthority + private readonly paneAgentCommandTrackers = new Map() + private readonly paneAgentIdentityOwnedIncarnations = new Map() + private readonly paneAgentIdentityLiveHookObservedAtByPty = new Map() + private readonly sshTypedAgentCandidates = new Map< + string, + { + agent: TuiAgent + incarnationId: PtyIncarnationId | null + lifecycleGeneration: number + commandOrdinal: number + timer: ReturnType + } + >() private readonly startedAt = Date.now() private readonly store: RuntimeStore | null private managedHookReconciliationGeneration = 0 @@ -3874,9 +3901,16 @@ export class OrcaRuntimeService { agentSessionClaimSigner?: AgentSessionClaimSigner orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport skillTransactionRecovery?: Promise + paneAgentIdentityCensus?: PaneAgentIdentityCensus } ) { this.store = store + this.paneAgentIdentityCensus = + deps?.paneAgentIdentityCensus ?? new PaneAgentIdentityCensus({ emit: null }) + this.paneAgentIdentityObservations = new PaneAgentIdentityObservationAuthority( + this.runtimeId, + this.paneAgentIdentityCensus + ) // Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return. const persistedClientTabSelections = store?.getMobileClientTabSelections?.() if (persistedClientTabSelections) { @@ -11442,7 +11476,11 @@ export class OrcaRuntimeService { tabId: string leafId: string incarnationId?: PtyIncarnationId - agentLaunchAuthority?: { launchToken: string; launchAgent: TuiAgent } + agentLaunchAuthority?: { + launchToken: string + launchAgent: TuiAgent + launchMode?: 'orca-launch' | 'resume' + } }, isWsl?: boolean ): void { @@ -11480,6 +11518,11 @@ export class OrcaRuntimeService { pty.launchToken = agentLaunchAuthority.launchToken pty.launchIncarnationId = binding.incarnationId pty.launchAgent = agentLaunchAuthority.launchAgent + this.observePtyAgentLaunch( + pty, + agentLaunchAuthority.launchMode ?? 'orca-launch', + agentLaunchAuthority.launchToken + ) } const pendingIncarnation = this.pendingPtyRegistrationIncarnations.get(ptyId) if ( @@ -12452,6 +12495,13 @@ export class OrcaRuntimeService { pty.lastOscTitleEpochMs = observedAtEpochMs pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true + const titleAgent = resolveExplicitTerminalTitleAgentType(rawTitle) + if (titleAgent) { + this.paneAgentIdentityObservations.observeTitle( + this.getPaneAgentIdentityPtyContext(pty), + titleAgent + ) + } if (prevStatus !== agentStatus) { pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs } @@ -12551,7 +12601,13 @@ export class OrcaRuntimeService { private resetTrackedTerminalStateForProviderGeneration(ptyId: string): void { // Why: a replacement daemon session can reuse the PTY id, but title/parser // state from the prior process must not bleed into its snapshots or chunks. + const previousPty = this.ptysById.get(ptyId) + this.releasePaneAgentIdentityOwner(ptyId, previousPty?.incarnationId) + this.paneAgentIdentityObservations.exitOrRebind(ptyId, previousPty?.incarnationId) this.disposePtyTitleTracker(ptyId) + this.paneAgentCommandTrackers.delete(ptyId) + this.paneAgentIdentityLiveHookObservedAtByPty.delete(ptyId) + this.clearSshTypedAgentCandidate(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) @@ -12721,6 +12777,19 @@ export class OrcaRuntimeService { } let retainedChanged = false for (const payload of chunk.payloads) { + if (pty && isTuiAgent(payload.agentType)) { + this.rememberPaneAgentIdentityHookSurface(ptyId, payload.state) + if (connectionId && payload.state !== 'done') { + this.corroborateSshTypedAgent(ptyId, payload.agentType, 'live-hook') + } + this.paneAgentIdentityObservations.observeEvidence( + this.getPaneAgentIdentityPtyContext(pty), + { + source: payload.state === 'done' ? 'completed-hook' : 'live-hook', + agent: payload.agentType + } + ) + } this.recordAgentPromptLifecycleState( ptyId, mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) @@ -14662,6 +14731,8 @@ export class OrcaRuntimeService { private retirePtyAgentLaunchAuthority(ptyId: string): void { const pty = this.ptysById.get(ptyId) + this.releasePaneAgentIdentityOwner(ptyId, pty?.incarnationId) + this.paneAgentIdentityLiveHookObservedAtByPty.delete(ptyId) if (!pty) { return } @@ -15924,6 +15995,8 @@ export class OrcaRuntimeService { if (exitIncarnationId && pty?.incarnationId && exitIncarnationId !== pty.incarnationId) { return } + this.paneAgentCommandTrackers.delete(ptyId) + this.clearSshTypedAgentCandidate(ptyId) // Why intent first: a requested stop can still be delivered by the provider's // own exit event, whose status looks exactly like a natural finish. // @@ -15944,6 +16017,10 @@ export class OrcaRuntimeService { pty?.connectionId != null && exitCode < 0 && options?.hostExitConfirmed !== true + if (!preservesAbnormalSshSurface && pty) { + this.paneAgentIdentityLiveHookObservedAtByPty.delete(ptyId) + this.paneAgentIdentityObservations.exitOrRebind(ptyId, pty.incarnationId) + } // Why: collect before retirePtyAgentLaunchAuthority, which deletes the restored-authority // receipt a receipt-only pane's key comes from. const exitPaneKeys = this.collectPaneKeysForPty(ptyId) @@ -18022,6 +18099,7 @@ export class OrcaRuntimeService { return { terminals: listedTerminals, + agentIdentityAvailability: this.paneAgentIdentityCensus.snapshot(), hostScope: this.buildTerminalListHostScope( targetWorktreeId, matchingTerminals, @@ -18040,6 +18118,269 @@ export class OrcaRuntimeService { } } + recordPaneAgentIdentityAvailability(observation: PaneAgentIdentityAvailability): void { + this.paneAgentIdentityCensus.record(observation) + } + + getPaneAgentIdentityCensus(): PaneAgentIdentityCensus { + return this.paneAgentIdentityCensus + } + + private getPaneAgentIdentityPtyContext(pty: RuntimePtyWorktreeRecord): { + ptyId: string + incarnationId: PtyIncarnationId | null + hostKinds: readonly PaneAgentIdentityHostKind[] + } { + const hostKinds: readonly PaneAgentIdentityHostKind[] = pty.connectionId + ? ['ssh'] + : pty.isWsl || pty.wslDistro || this.wslDistroByPtyId.has(pty.ptyId) + ? ['wsl-host', 'wsl-distro'] + : ['native'] + return { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + hostKinds: this.paneAgentIdentityCensus.hostKindsForObservation(hostKinds) + } + } + + private observePtyAgentLaunch( + pty: RuntimePtyWorktreeRecord, + launchMode: 'orca-launch' | 'resume', + attestationId: string + ): void { + if (!pty.launchAgent) { + return + } + if ( + this.paneAgentIdentityObservations.attestRun( + this.getPaneAgentIdentityPtyContext(pty), + launchMode, + pty.launchAgent, + `${launchMode}:${attestationId}` + ) + ) { + this.paneAgentIdentityOwnedIncarnations.set(pty.ptyId, pty.incarnationId) + } + } + + private releasePaneAgentIdentityOwner( + ptyId: string, + incarnationId?: PtyIncarnationId | null + ): void { + if ( + !this.paneAgentIdentityOwnedIncarnations.has(ptyId) || + (incarnationId !== undefined && + this.paneAgentIdentityOwnedIncarnations.get(ptyId) !== incarnationId) + ) { + return + } + this.paneAgentIdentityOwnedIncarnations.delete(ptyId) + this.paneAgentCommandTrackers.get(ptyId)?.reset() + } + + observeAgentHookStatus(args: { + paneKey: string + connectionId?: string | null + isReplay?: boolean + restoredUnconfirmed?: boolean + providerSessionOnly?: boolean + payload: { state: string; agentType?: string } + }): boolean { + if ( + args.isReplay === true || + args.restoredUnconfirmed === true || + args.providerSessionOnly === true || + !isTuiAgent(args.payload.agentType) + ) { + return false + } + const pty = this.getPtyRecordForPaneKey(args.paneKey) + const isWslRelay = isWslHookRelayConnectionId(args.connectionId) + const hookOwnerConnectionId = isWslRelay ? null : (args.connectionId ?? null) + const isWslPty = + pty?.isWsl === true || + !!pty?.wslDistro || + (pty ? this.wslDistroByPtyId.has(pty.ptyId) : false) + if ( + !pty?.connected || + (pty.connectionId ?? null) !== hookOwnerConnectionId || + (isWslRelay && !isWslPty) + ) { + return false + } + if (pty.connectionId && args.payload.state !== 'done') { + this.corroborateSshTypedAgent(pty.ptyId, args.payload.agentType, 'live-hook') + } + this.rememberPaneAgentIdentityHookSurface(pty.ptyId, args.payload.state) + return this.paneAgentIdentityObservations.observeEvidence( + this.getPaneAgentIdentityPtyContext(pty), + { + source: args.payload.state === 'done' ? 'completed-hook' : 'live-hook', + agent: args.payload.agentType + } + ) + } + + observeAcceptedPtyWrite(ptyId: string, data: string): void { + const pty = this.ptysById.get(ptyId) + if (!pty?.connected) { + return + } + if (this.paneAgentIdentityOwnedIncarnations.get(ptyId) === pty.incarnationId) { + this.paneAgentCommandTrackers.get(ptyId)?.reset() + return + } + if (this.hasFreshPaneAgentIdentitySurface(pty)) { + this.paneAgentCommandTrackers.get(ptyId)?.reset() + return + } + const tracker = this.paneAgentCommandTrackers.get(ptyId) ?? new ManualAgentCommandTracker() + this.paneAgentCommandTrackers.set(ptyId, tracker) + const [agent] = tracker.ingest(data) + if (!agent) { + return + } + if (pty.connectionId) { + this.rememberSshTypedAgentCandidate(ptyId, agent, pty, tracker.acceptedCommandCount) + return + } + const context = this.getPaneAgentIdentityPtyContext(pty) + if ( + this.paneAgentIdentityObservations.attestRun( + context, + 'typed', + agent, + `typed:${this.getPtyLifecycleGeneration(ptyId)}:${tracker.acceptedCommandCount}` + ) + ) { + this.paneAgentIdentityOwnedIncarnations.set(ptyId, pty.incarnationId) + } + } + + private rememberSshTypedAgentCandidate( + ptyId: string, + agent: TuiAgent, + pty: RuntimePtyWorktreeRecord, + commandOrdinal: number + ): void { + const previous = this.sshTypedAgentCandidates.get(ptyId) + if (!previous && this.sshTypedAgentCandidates.size >= MAX_PENDING_IDENTITY_OBSERVATIONS) { + this.paneAgentIdentityCensus.addCoverage('ssh', 'overflow') + return + } + if (previous) { + clearTimeout(previous.timer) + } + const timer = setTimeout( + () => this.clearSshTypedAgentCandidate(ptyId), + TITLE_CANDIDATE_WINDOW_MS + ) + timer.unref?.() + this.sshTypedAgentCandidates.set(ptyId, { + agent, + incarnationId: pty.incarnationId, + lifecycleGeneration: this.getPtyLifecycleGeneration(ptyId), + commandOrdinal, + timer + }) + } + + private rememberPaneAgentIdentityHookSurface(ptyId: string, state: string): void { + if (state === 'done') { + this.paneAgentIdentityLiveHookObservedAtByPty.delete(ptyId) + return + } + this.paneAgentIdentityLiveHookObservedAtByPty.set(ptyId, Date.now()) + } + + private hasFreshPaneAgentIdentitySurface(pty: RuntimePtyWorktreeRecord): boolean { + if (pty.foregroundAgent || (pty.lastAgentStatusObservedLive && pty.lastAgentStatus !== null)) { + return true + } + const hookObservedAt = this.paneAgentIdentityLiveHookObservedAtByPty.get(pty.ptyId) + if (hookObservedAt === undefined) { + return false + } + if (Date.now() - hookObservedAt <= AGENT_STATUS_STALE_AFTER_MS) { + return true + } + this.paneAgentIdentityLiveHookObservedAtByPty.delete(pty.ptyId) + return false + } + + private clearSshTypedAgentCandidate(ptyId: string): void { + const candidate = this.sshTypedAgentCandidates.get(ptyId) + if (candidate) { + clearTimeout(candidate.timer) + } + this.sshTypedAgentCandidates.delete(ptyId) + } + + corroborateSshTypedAgent( + ptyId: string, + targetAgent: TuiAgent, + source: 'process' | 'live-hook' = 'process' + ): boolean { + const candidate = this.sshTypedAgentCandidates.get(ptyId) + const pty = this.ptysById.get(ptyId) + if ( + candidate?.agent !== targetAgent || + !pty?.connected || + !pty.connectionId || + candidate.incarnationId !== pty.incarnationId || + candidate.lifecycleGeneration !== this.getPtyLifecycleGeneration(ptyId) + ) { + return false + } + this.clearSshTypedAgentCandidate(ptyId) + const context = this.getPaneAgentIdentityPtyContext(pty) + if ( + !this.paneAgentIdentityObservations.attestRun( + context, + 'typed', + targetAgent, + `typed:${candidate.lifecycleGeneration}:${candidate.commandOrdinal}` + ) + ) { + return false + } + this.paneAgentIdentityOwnedIncarnations.set(ptyId, pty.incarnationId) + if (source === 'process') { + this.paneAgentIdentityObservations.observeEvidence(context, { + source, + agent: targetAgent, + processProvenance: 'target-origin' + }) + } else { + this.paneAgentIdentityObservations.observeEvidence(context, { + source, + agent: targetAgent + }) + } + return true + } + + shutdownPaneAgentIdentityCensus(emitTelemetry = true): void { + if (this.sshTypedAgentCandidates.size > 0) { + this.paneAgentIdentityCensus.addCoverage( + 'ssh', + 'truncated', + this.sshTypedAgentCandidates.size + ) + } + this.paneAgentIdentityObservations.shutdown() + if (emitTelemetry) { + this.paneAgentIdentityCensus.flush() + } + this.paneAgentIdentityCensus.shutdown() + this.paneAgentCommandTrackers.clear() + this.paneAgentIdentityOwnedIncarnations.clear() + this.paneAgentIdentityLiveHookObservedAtByPty.clear() + for (const ptyId of this.sshTypedAgentCandidates.keys()) { + this.clearSshTypedAgentCandidate(ptyId) + } + } + // A worktree-scoped request answers for one host only, so name the hosts it // skipped: absence from a scoped listing is not evidence a worker exited. private buildTerminalListHostScope( @@ -19362,6 +19703,7 @@ export class OrcaRuntimeService { } await assertTerminalInputWithinLimitWithYield(action.text) await this.writeTerminalAction(pty.pty.ptyId, action, payload, options) + this.observeAcceptedPtyWrite(pty.pty.ptyId, payload) return { handle, accepted: true, @@ -19387,6 +19729,7 @@ export class OrcaRuntimeService { } await this.writeTerminalAction(leaf.ptyId, action, payload, options) + this.observeAcceptedPtyWrite(leaf.ptyId, payload) return { handle, @@ -19861,6 +20204,7 @@ export class OrcaRuntimeService { } return } + this.releasePaneAgentIdentityOwner(ptyId, current.incarnationId) this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) }) } @@ -19984,9 +20328,24 @@ export class OrcaRuntimeService { const foregroundAgent = foregroundProcess ? (recognizeAgentProcess(foregroundProcess)?.agent ?? null) : null + const isWsl = pty.isWsl || pty.wslDistro || this.wslDistroByPtyId.has(ptyId) + if (foregroundAgent && !isWsl) { + const processProvenance = pty.connectionId ? 'target-origin' : 'execution-host' + this.paneAgentIdentityObservations.observeEvidence(this.getPaneAgentIdentityPtyContext(pty), { + source: 'process', + agent: foregroundAgent, + processProvenance + }) + if (pty.connectionId) { + this.corroborateSshTypedAgent(ptyId, foregroundAgent, 'process') + } + } if (pty.foregroundAgent === foregroundAgent) { return false } + if (pty.foregroundAgent && !foregroundAgent) { + this.releasePaneAgentIdentityOwner(ptyId, pty.incarnationId) + } pty.foregroundAgent = foregroundAgent this.touchMobileSessionSnapshotsForPty(ptyId) return true @@ -29183,6 +29542,15 @@ export class OrcaRuntimeService { pty.launchToken = launchToken ?? null pty.launchIncarnationId = launchToken ? pty.incarnationId : null pty.launchAgent = launchOpts.launchAgent ?? null + if (pty.launchAgent && launchToken) { + this.observePtyAgentLaunch( + pty, + launchOpts.resumeProviderSession || launchOpts.agentSessionClaim + ? 'resume' + : 'orca-launch', + launchToken + ) + } } pty.tabId = tabId pty.paneKey = paneKey @@ -33349,6 +33717,11 @@ export class OrcaRuntimeService { pty.worktreeId = worktreeId if (state.incarnationId !== undefined) { if (pty.incarnationId && state.incarnationId && pty.incarnationId !== state.incarnationId) { + this.releasePaneAgentIdentityOwner(ptyId, pty.incarnationId) + this.paneAgentCommandTrackers.delete(ptyId) + this.paneAgentIdentityLiveHookObservedAtByPty.delete(ptyId) + this.clearSshTypedAgentCandidate(ptyId) + this.paneAgentIdentityObservations.exitOrRebind(ptyId, pty.incarnationId) this.invalidatePtyIncarnationHandle(ptyId) } pty.incarnationId = state.incarnationId @@ -33839,7 +34212,13 @@ export class OrcaRuntimeService { private dropDisconnectedPtyRecord(ptyId: string): void { // Why: pruning can remove a PTY without the normal exit callback. + const pty = this.ptysById.get(ptyId) + this.releasePaneAgentIdentityOwner(ptyId, pty?.incarnationId) + this.paneAgentIdentityObservations.exitOrRebind(ptyId, pty?.incarnationId) this.advancePtyLifecycleGeneration(ptyId) + this.paneAgentCommandTrackers.delete(ptyId) + this.paneAgentIdentityLiveHookObservedAtByPty.delete(ptyId) + this.clearSshTypedAgentCandidate(ptyId) this.pairedRendererSessionOwnedPtyIds.delete(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) diff --git a/src/main/runtime/pane-agent-identity-census-runtime.test.ts b/src/main/runtime/pane-agent-identity-census-runtime.test.ts new file mode 100644 index 00000000000..c8e8f2d06b8 --- /dev/null +++ b/src/main/runtime/pane-agent-identity-census-runtime.test.ts @@ -0,0 +1,511 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { track } from '../telemetry/client' +import { PaneAgentIdentityCensus } from '../telemetry/pane-agent-identity-census' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +async function availabilityRows(runtime: OrcaRuntimeService) { + return ((await runtime.listTerminals()).agentIdentityAvailability?.rows ?? []).map((row) => ({ + hostKind: row[0], + launchMode: row[1], + attestedRuns: row[2], + noEvidence: row[3], + titleOnly: row[4], + identityNull: row[5], + ambiguousTopRank: row[6] + })) +} + +describe('runtime pane identity census wiring', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('publishes paired WSL typed views without a false native row', async () => { + vi.useFakeTimers() + vi.mocked(track).mockClear() + const runtime = new OrcaRuntimeService(undefined, undefined, { + paneAgentIdentityCensus: new PaneAgentIdentityCensus() + }) + runtime.registerPty('pty-wsl', 'folder:/tmp', null, undefined, true) + + runtime.observeAcceptedPtyWrite('pty-wsl', 'codex\r') + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'wsl-host', launchMode: 'typed', attestedRuns: 1 }), + expect.objectContaining({ hostKind: 'wsl-distro', launchMode: 'typed', attestedRuns: 1 }) + ]) + runtime.shutdownPaneAgentIdentityCensus() + expect(track).toHaveBeenCalledTimes(1) + expect(track).toHaveBeenCalledWith('pane_agent_identity_availability', { + rows: expect.arrayContaining([ + expect.objectContaining({ host_kind: 'wsl-host', attested_runs: 1 }), + expect.objectContaining({ host_kind: 'wsl-distro', attested_runs: 1 }) + ]) + }) + }) + + it('does not relabel a Windows-side WSL foreground read as distro process provenance', async () => { + vi.useFakeTimers() + const census = new PaneAgentIdentityCensus({ emit: null }) + const record = vi.spyOn(census, 'record') + const runtime = new OrcaRuntimeService(undefined, undefined, { + paneAgentIdentityCensus: census + }) + runtime.registerPty('pty-wsl', 'folder:/tmp', null, undefined, true) + runtime.setPtyController({ getForegroundProcess: async () => 'codex' } as never) + runtime.observeAcceptedPtyWrite('pty-wsl', 'codex\r') + await ( + runtime as unknown as { + loadPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).loadPtyForegroundAgentFromController('pty-wsl') + vi.advanceTimersByTime(5_000) + + expect(record).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ hostKind: 'wsl-host', sourceMask: 4 }) + ) + expect(record).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ hostKind: 'wsl-distro', sourceMask: 4 }) + ) + expect(census.snapshot().rows).toEqual([ + ['wsl-host', 'typed', 1, 0, 0, 0, 0], + ['wsl-distro', 'typed', 1, 0, 0, 0, 0] + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('requires same-incarnation SSH target corroboration', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const binding = { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002', + incarnationId: 'incarnation-1' + } + runtime.registerPty('pty-ssh', 'folder:/tmp', 'ssh-1', binding) + runtime.observeAcceptedPtyWrite('pty-ssh', 'codex\r') + + expect(runtime.corroborateSshTypedAgent('pty-ssh', 'claude')).toBe(false) + expect(await availabilityRows(runtime)).toEqual([]) + + runtime.registerPty('pty-ssh', 'folder:/tmp', 'ssh-1', { + ...binding, + incarnationId: 'incarnation-2' + }) + expect(runtime.corroborateSshTypedAgent('pty-ssh', 'codex')).toBe(false) + expect(await availabilityRows(runtime)).toEqual([]) + + runtime.observeAcceptedPtyWrite('pty-ssh', 'codex\r') + runtime.onPtyData( + 'pty-ssh', + '\x1b]9999;{"state":"working","agentType":"codex"}\x07', + Date.now() + ) + vi.advanceTimersByTime(5_000) + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'ssh', launchMode: 'typed', attestedRuns: 1 }) + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('does not let a replayed SSH hook certify a typed run', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const tabId = '00000000-0000-4000-8000-000000000001' + const leafId = '00000000-0000-4000-8000-000000000002' + runtime.registerPty('pty-ssh', 'folder:/tmp', 'ssh-1', { + tabId, + leafId, + incarnationId: 'incarnation-1' + }) + runtime.observeAcceptedPtyWrite('pty-ssh', 'codex\r') + + expect( + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${leafId}`, + connectionId: 'ssh-1', + isReplay: true, + payload: { state: 'working', agentType: 'codex' } + }) + ).toBe(false) + vi.advanceTimersByTime(5_000) + expect(await availabilityRows(runtime)).toEqual([]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('accepts live WSL-relayed hook evidence only for its local WSL pane', async () => { + vi.useFakeTimers() + const census = new PaneAgentIdentityCensus({ emit: null }) + const record = vi.spyOn(census, 'record') + const runtime = new OrcaRuntimeService(undefined, undefined, { + paneAgentIdentityCensus: census + }) + const tabId = '00000000-0000-4000-8000-000000000001' + const wslLeafId = '00000000-0000-4000-8000-000000000002' + const nativeLeafId = '00000000-0000-4000-8000-000000000003' + runtime.registerPty( + 'pty-wsl', + 'folder:/tmp', + null, + { + tabId, + leafId: wslLeafId, + incarnationId: 'incarnation-1' + }, + true + ) + runtime.registerPty('pty-native', 'folder:/tmp', null, { + tabId, + leafId: nativeLeafId, + incarnationId: 'incarnation-1' + }) + runtime.observeAcceptedPtyWrite('pty-wsl', 'codex\r') + runtime.observeAcceptedPtyWrite('pty-native', 'codex\r') + + expect( + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${nativeLeafId}`, + connectionId: 'wsl:LiveDistro', + payload: { state: 'working', agentType: 'codex' } + }) + ).toBe(false) + expect( + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${wslLeafId}`, + connectionId: 'wsl:LiveDistro', + payload: { state: 'working', agentType: 'codex' } + }) + ).toBe(true) + vi.advanceTimersByTime(5_000) + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ hostKind: 'wsl-distro', sourceMask: 5 }) + ) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('records launch and resume through renderer registration authority', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const binding = { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002', + incarnationId: 'incarnation-1' + } + runtime.registerPty('pty-launch', 'folder:/tmp', null, { + ...binding, + agentLaunchAuthority: { + launchToken: 'launch-token', + launchAgent: 'codex', + launchMode: 'orca-launch' + } + }) + runtime.registerPty('pty-resume', 'folder:/tmp', null, { + ...binding, + leafId: '00000000-0000-4000-8000-000000000003', + agentLaunchAuthority: { + launchToken: 'resume-token', + launchAgent: 'claude', + launchMode: 'resume' + } + }) + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ hostKind: 'native', launchMode: 'orca-launch', attestedRuns: 1 }), + expect.objectContaining({ hostKind: 'native', launchMode: 'resume', attestedRuns: 1 }) + ]) + ) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('does not guess a launch mode for an unstructured legacy terminal create', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + vi.spyOn( + runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + }, + 'resolveTerminalWorkspaceLaunchScope' + ).mockResolvedValue({ + id: 'folder:/tmp', + path: '/tmp', + connectionId: null, + repo: null, + folderWorkspace: null + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-legacy', incarnationId: 'incarnation-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal('id:folder:/tmp', { + command: 'codex resume provider-session-1', + launchAgent: 'codex' + }) + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual([]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('does not classify live-agent prompt text as a typed launch', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const binding = { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002', + incarnationId: 'incarnation-1', + agentLaunchAuthority: { + launchToken: 'launch-token', + launchAgent: 'codex' as const, + launchMode: 'orca-launch' as const + } + } + runtime.registerPty('pty-native', 'folder:/tmp', null, binding) + runtime.observeAcceptedPtyWrite('pty-native', 'claude\r') + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'native', launchMode: 'orca-launch', attestedRuns: 1 }) + ]) + + runtime.emitDaemonPtyTransientFact('pty-native', { + kind: 'command-finished', + exitCode: 0 + }) + runtime.observeAcceptedPtyWrite('pty-native', 'claude\r') + vi.advanceTimersByTime(5_000) + expect(await availabilityRows(runtime)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ launchMode: 'orca-launch', attestedRuns: 1 }), + expect.objectContaining({ launchMode: 'typed', attestedRuns: 1 }) + ]) + ) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('does not classify prompt text while a fresh hook owns an otherwise unattributed pane', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const tabId = '00000000-0000-4000-8000-000000000001' + const leafId = '00000000-0000-4000-8000-000000000002' + runtime.registerPty('pty-native', 'folder:/tmp', null, { + tabId, + leafId, + incarnationId: 'incarnation-1' + }) + + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${leafId}`, + payload: { state: 'working', agentType: 'codex' } + }) + runtime.observeAcceptedPtyWrite('pty-native', 'claude\r') + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual([]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('does not carry a partial typed command across a PTY incarnation rebind', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const binding = { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002' + } + runtime.registerPty('pty-native', 'folder:/tmp', null, { + ...binding, + incarnationId: 'incarnation-1' + }) + runtime.observeAcceptedPtyWrite('pty-native', 'co') + runtime.registerPty('pty-native', 'folder:/tmp', null, { + ...binding, + incarnationId: 'incarnation-2' + }) + runtime.observeAcceptedPtyWrite('pty-native', 'dex\r') + vi.advanceTimersByTime(5_000) + expect(await availabilityRows(runtime)).toEqual([]) + + runtime.observeAcceptedPtyWrite('pty-native', 'codex\r') + vi.advanceTimersByTime(5_000) + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'native', launchMode: 'typed', attestedRuns: 1 }) + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('attests only the first agent command in one accepted multiline write', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const tabId = '00000000-0000-4000-8000-000000000001' + const leafId = '00000000-0000-4000-8000-000000000002' + runtime.registerPty('pty-ssh', 'folder:/tmp', 'ssh-1', { + tabId, + leafId, + incarnationId: 'incarnation-1' + }) + + runtime.observeAcceptedPtyWrite('pty-ssh', 'claude\rcodex\r') + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${leafId}`, + connectionId: 'ssh-1', + payload: { state: 'working', agentType: 'claude' } + }) + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'ssh', launchMode: 'typed', attestedRuns: 1 }) + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('collects primary hook-server evidence for the exact live pane', async () => { + vi.useFakeTimers() + const census = new PaneAgentIdentityCensus({ emit: null }) + const record = vi.spyOn(census, 'record') + const runtime = new OrcaRuntimeService(undefined, undefined, { + paneAgentIdentityCensus: census + }) + const tabId = '00000000-0000-4000-8000-000000000001' + const leafId = '00000000-0000-4000-8000-000000000002' + runtime.registerPty('pty-native', 'folder:/tmp', null, { + tabId, + leafId, + incarnationId: 'incarnation-1', + agentLaunchAuthority: { + launchToken: 'launch-token', + launchAgent: 'codex', + launchMode: 'orca-launch' + } + }) + + expect( + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${leafId}`, + connectionId: 'wrong-host', + payload: { state: 'working', agentType: 'codex' } + }) + ).toBe(false) + expect( + runtime.observeAgentHookStatus({ + paneKey: `${tabId}:${leafId}`, + payload: { state: 'working', agentType: 'codex' } + }) + ).toBe(true) + vi.advanceTimersByTime(5_000) + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ hostKind: 'native', sourceMask: 5 }) + ) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('normalizes headless authority observations to one relay row per run', async () => { + vi.useFakeTimers() + const census = new PaneAgentIdentityCensus({ emit: null, snapshotHostKind: 'relay' }) + const runtime = new OrcaRuntimeService(undefined, undefined, { + paneAgentIdentityCensus: census + }) + runtime.registerPty('pty-wsl', 'folder:/tmp', null, undefined, true) + runtime.observeAcceptedPtyWrite('pty-wsl', 'codex\r') + vi.advanceTimersByTime(5_000) + + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'relay', launchMode: 'typed', attestedRuns: 1 }) + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('collects current process, hook, and title evidence through PTY production paths', async () => { + vi.useFakeTimers() + const census = new PaneAgentIdentityCensus({ emit: null }) + const record = vi.spyOn(census, 'record') + const runtime = new OrcaRuntimeService(undefined, undefined, { + paneAgentIdentityCensus: census + }) + runtime.registerPty('pty-native', 'folder:/tmp', null, { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002', + incarnationId: 'incarnation-1' + }) + runtime.setPtyController({ getForegroundProcess: async () => 'codex' } as never) + runtime.observeAcceptedPtyWrite('pty-native', 'codex\r') + await ( + runtime as unknown as { + loadPtyForegroundAgentFromController: (ptyId: string) => Promise + } + ).loadPtyForegroundAgentFromController('pty-native') + runtime.onPtyData( + 'pty-native', + '\x1b]9999;{"state":"working","agentType":"codex"}\x07' + + '\x1b]9999;{"state":"done","agentType":"codex"}\x07' + + '\x1b]0;Codex working\x07', + Date.now() + ) + vi.advanceTimersByTime(5_000) + + expect(record).toHaveBeenCalledWith({ + hostKind: 'native', + launchMode: 'typed', + sourceMask: 79, + identityNull: false, + ambiguousTopRank: false + }) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('freezes an accepted run on a confirmed production exit', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + runtime.registerPty('pty-native', 'folder:/tmp', null, { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002', + incarnationId: 'incarnation-1' + }) + runtime.observeAcceptedPtyWrite('pty-native', 'codex\r') + runtime.onPtyExit('pty-native', 0, 'incarnation-1', { providerExitObserved: true }) + + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'native', launchMode: 'typed', attestedRuns: 1 }) + ]) + vi.advanceTimersByTime(5_000) + expect(await availabilityRows(runtime)).toHaveLength(1) + runtime.shutdownPaneAgentIdentityCensus(false) + }) + + it('publishes title-candidate coverage and freezes a run on production rebind', async () => { + vi.useFakeTimers() + const runtime = new OrcaRuntimeService() + const binding = { + tabId: '00000000-0000-4000-8000-000000000001', + leafId: '00000000-0000-4000-8000-000000000002', + incarnationId: 'incarnation-1' + } + runtime.registerPty('pty-title', 'folder:/tmp', null, binding) + runtime.onPtyData('pty-title', '\x1b]0;Codex working\x07', Date.now()) + vi.advanceTimersByTime(30_000) + expect((await runtime.listTerminals()).agentIdentityAvailability?.candidateCoverage).toEqual([ + ['native', 1] + ]) + + runtime.registerPty('pty-rebind', 'folder:/tmp', null, binding) + runtime.observeAcceptedPtyWrite('pty-rebind', 'codex\r') + runtime.registerPty('pty-rebind', 'folder:/tmp', null, { + ...binding, + incarnationId: 'incarnation-2' + }) + expect(await availabilityRows(runtime)).toEqual([ + expect.objectContaining({ hostKind: 'native', launchMode: 'typed', attestedRuns: 1 }) + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + }) +}) diff --git a/src/main/runtime/pane-agent-identity-observation-authority.test.ts b/src/main/runtime/pane-agent-identity-observation-authority.test.ts new file mode 100644 index 00000000000..4103f49128b --- /dev/null +++ b/src/main/runtime/pane-agent-identity-observation-authority.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PaneAgentIdentityAvailability } from '../../shared/pane-agent-identity-evidence' +import { MAX_LIVE_FINALIZED_RUN_KEYS } from '../../shared/pane-agent-identity-availability' +import { PaneAgentIdentityObservationAuthority } from './pane-agent-identity-observation-authority' + +function createHarness() { + const records: PaneAgentIdentityAvailability[] = [] + const coverage: { hostKind: string; reason: string; amount: number }[] = [] + const authority = new PaneAgentIdentityObservationAuthority('authority', { + record: (row) => records.push(row), + addCoverage: (hostKind, reason, amount = 1) => coverage.push({ hostKind, reason, amount }) + }) + return { authority, records, coverage } +} + +describe('PaneAgentIdentityObservationAuthority', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('settles once after five seconds and dedupes the finalized attestation', () => { + vi.useFakeTimers() + const { authority, records } = createHarness() + const context = { ptyId: 'pty', incarnationId: 'inc-1', hostKinds: ['native'] as const } + + expect(authority.attestRun(context, 'typed', 'codex', 'typed:1')).toBe(true) + vi.advanceTimersByTime(4_999) + expect(records).toEqual([]) + vi.advanceTimersByTime(1) + expect(records).toEqual([ + expect.objectContaining({ hostKind: 'native', launchMode: 'typed', sourceMask: 4 }) + ]) + + expect(authority.attestRun(context, 'typed', 'codex', 'typed:1')).toBe(true) + vi.advanceTimersByTime(5_000) + expect(records).toHaveLength(1) + + expect(authority.attestRun(context, 'typed', 'claude', 'typed:2')).toBe(true) + vi.advanceTimersByTime(5_000) + expect(records).toHaveLength(2) + authority.shutdown() + }) + + it('publishes paired WSL facts with execution evidence only on the distro view', () => { + vi.useFakeTimers() + const { authority, records } = createHarness() + const context = { + ptyId: 'pty', + incarnationId: 'inc-1', + hostKinds: ['wsl-host', 'wsl-distro'] as const + } + authority.attestRun(context, 'orca-launch', 'codex', 'launch:1') + authority.observeEvidence(context, { + source: 'process', + agent: 'codex', + processProvenance: 'distro-origin' + }) + authority.observeEvidence(context, { source: 'live-hook', agent: 'codex' }) + authority.observeTitle(context, 'codex') + + vi.advanceTimersByTime(5_000) + + expect(records).toEqual([ + expect.objectContaining({ hostKind: 'wsl-host', sourceMask: 4 }), + expect.objectContaining({ hostKind: 'wsl-distro', sourceMask: 71 }) + ]) + authority.shutdown() + }) + + it('freezes pending runs immediately on exit and truncates only live windows on shutdown', () => { + vi.useFakeTimers() + const { authority, records, coverage } = createHarness() + const first = { ptyId: 'pty-1', incarnationId: 'inc-1', hostKinds: ['ssh'] as const } + const second = { ptyId: 'pty-2', incarnationId: 'inc-1', hostKinds: ['native'] as const } + authority.attestRun(first, 'resume', 'claude', 'resume:1') + authority.exitOrRebind(first.ptyId, first.incarnationId) + expect(records).toEqual([ + expect.objectContaining({ hostKind: 'ssh', launchMode: 'resume', sourceMask: 20 }) + ]) + + authority.attestRun(second, 'typed', 'codex', 'typed:1') + authority.shutdown() + expect(coverage).toContainEqual({ hostKind: 'native', reason: 'truncated', amount: 1 }) + vi.advanceTimersByTime(5_000) + expect(records).toHaveLength(1) + }) + + it('records a title candidate only after its authority window and cancels it on attestation', () => { + vi.useFakeTimers() + const { authority, coverage } = createHarness() + const context = { ptyId: 'pty', incarnationId: 'inc-1', hostKinds: ['ssh'] as const } + authority.observeTitle(context, 'codex') + vi.advanceTimersByTime(29_999) + expect(coverage).toEqual([]) + vi.advanceTimersByTime(1) + expect(coverage).toEqual([{ hostKind: 'ssh', reason: 'candidate', amount: 1 }]) + + authority.exitOrRebind(context.ptyId, context.incarnationId) + authority.observeTitle(context, 'codex') + authority.attestRun(context, 'typed', 'codex', 'typed:1') + vi.advanceTimersByTime(30_000) + expect(coverage).toHaveLength(1) + + authority.observeTitle(context, 'codex') + vi.advanceTimersByTime(30_000) + expect(coverage).toHaveLength(1) + authority.shutdown() + }) + + it('releases a title candidate after the finalized-key cap rejects it', () => { + vi.useFakeTimers() + const { authority, coverage } = createHarness() + for (let index = 0; index < MAX_LIVE_FINALIZED_RUN_KEYS; index += 1) { + authority.attestRun( + { ptyId: `run-${index}`, incarnationId: 'inc-1', hostKinds: ['native'] }, + 'typed', + 'codex', + `typed:${index}` + ) + } + vi.advanceTimersByTime(5_000) + + const candidate = { + ptyId: 'candidate', + incarnationId: 'inc-1', + hostKinds: ['native'] as const + } + authority.observeTitle(candidate, 'codex') + vi.advanceTimersByTime(30_000) + expect(coverage).toContainEqual({ hostKind: 'native', reason: 'overflow', amount: 1 }) + + authority.observeTitle(candidate, 'codex') + vi.advanceTimersByTime(30_000) + authority.shutdown() + expect(coverage).toEqual([{ hostKind: 'native', reason: 'overflow', amount: 1 }]) + }) + + it('tombstones a title candidate when the pending-window cap rejects it', () => { + vi.useFakeTimers() + const { authority, coverage } = createHarness() + for (let index = 0; index < MAX_LIVE_FINALIZED_RUN_KEYS; index += 1) { + authority.attestRun( + { ptyId: `run-${index}`, incarnationId: 'inc-1', hostKinds: ['native'] }, + 'typed', + 'codex', + `typed:${index}` + ) + } + + const candidate = { + ptyId: 'candidate', + incarnationId: 'inc-1', + hostKinds: ['native'] as const + } + authority.observeTitle(candidate, 'codex') + authority.observeTitle(candidate, 'codex') + expect(coverage).toEqual([{ hostKind: 'native', reason: 'overflow', amount: 1 }]) + authority.shutdown() + }) +}) diff --git a/src/main/runtime/pane-agent-identity-observation-authority.ts b/src/main/runtime/pane-agent-identity-observation-authority.ts new file mode 100644 index 00000000000..ee85cf82df9 --- /dev/null +++ b/src/main/runtime/pane-agent-identity-observation-authority.ts @@ -0,0 +1,301 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TuiAgent } from '../../shared/tui-agent' +import { + PaneAgentIdentityObservationScheduler, + type PaneAgentIdentityObservationSchedulerDeps +} from '../../shared/pane-agent-identity-availability' +import { + reducePaneAgentIdentityAvailability, + type PaneAgentIdentityAvailability, + type PaneAgentIdentityEvidenceFact, + type PaneAgentIdentityHostKind, + type PaneAgentIdentityLaunchMode +} from '../../shared/pane-agent-identity-evidence' +import type { PaneAgentRunKey } from '../../shared/pane-agent-identity-resolver' +import { + appendEvidenceToActiveRun, + titleHostKind, + uniqueHostKinds, + type ActiveRun, + type Candidate, + type EvidenceObservation, + type PtyObservationState +} from './pane-agent-identity-observation-model' + +type CensusCoverageReason = 'candidate' | 'overflow' | 'truncated' + +export type PaneAgentIdentityObservationSink = { + record(observation: PaneAgentIdentityAvailability): void + addCoverage( + hostKind: PaneAgentIdentityHostKind, + reason: CensusCoverageReason, + amount?: number + ): void +} + +export type PaneAgentIdentityPtyContext = { + ptyId: string + incarnationId: PtyIncarnationId | null + hostKinds: readonly PaneAgentIdentityHostKind[] +} + +/** Owns run/candidate windows and reduces authority facts before they reach a sink. */ +export class PaneAgentIdentityObservationAuthority { + private readonly states = new Map() + private readonly runsByKey = new Map() + private readonly candidatesByKey = new Map() + private readonly scheduler: PaneAgentIdentityObservationScheduler + + constructor( + private readonly authorityId: string, + private readonly sink: PaneAgentIdentityObservationSink, + schedulerDeps: Pick = {} + ) { + this.scheduler = new PaneAgentIdentityObservationScheduler({ + ...schedulerDeps, + onRunFreeze: (key) => this.freezeRun(key), + onTitleCandidateFreeze: (hostKind, key) => this.freezeCandidate(hostKind, key), + onFinalizedOverflow: (kind, hostKind, key) => + this.recordFinalizedOverflow(kind, hostKind, key) + }) + } + + attestRun( + context: PaneAgentIdentityPtyContext, + launchMode: PaneAgentIdentityLaunchMode, + owner: TuiAgent, + attestationId: string + ): boolean { + const state = this.getState(context) + if (state.lastAttestationId === attestationId) { + return true + } + if (state.activeRun) { + const previousRun = state.activeRun + if (!this.scheduler.freezeRun(previousRun.key)) { + for (const hostKind of previousRun.hostKinds) { + this.sink.addCoverage(hostKind, 'overflow') + } + this.runsByKey.delete(previousRun.key) + state.activeRun = null + } + } + if (state.candidate) { + this.scheduler.cancelCandidate(state.candidate.key) + this.scheduler.exitOrRebind(state.candidate.key) + this.candidatesByKey.delete(state.candidate.key) + state.candidate = null + } + if (state.finalizedRunKey) { + this.scheduler.exitOrRebind(state.finalizedRunKey) + state.finalizedRunKey = null + } + if (state.finalizedCandidateKey) { + this.scheduler.exitOrRebind(state.finalizedCandidateKey) + state.finalizedCandidateKey = null + } + state.ordinal += 1 + const run: PaneAgentRunKey = { + authorityId: `${this.authorityId}:${context.ptyId}:${context.incarnationId ?? 'unknown'}`, + incarnation: state.ordinal + } + const key = `${run.authorityId}:${run.incarnation}` + const hostKinds = uniqueHostKinds(context.hostKinds) + const factsByHostKind = new Map() + for (const hostKind of hostKinds) { + const facts: PaneAgentIdentityEvidenceFact[] = [{ source: 'launch', agent: owner, run }] + if (launchMode === 'resume') { + facts.push({ source: 'sleeping-session', agent: owner, run }) + } + factsByHostKind.set(hostKind, facts) + } + const activeRun: ActiveRun = { + key, + ptyId: context.ptyId, + run, + launchMode, + owner, + hostKinds, + factsByHostKind + } + state.lastAttestationId = attestationId + state.activeRun = activeRun + this.runsByKey.set(key, activeRun) + if (this.scheduler.attestRun(key)) { + return true + } + this.runsByKey.delete(key) + state.activeRun = null + for (const hostKind of hostKinds) { + this.sink.addCoverage(hostKind, 'overflow') + } + return false + } + + observeEvidence(context: PaneAgentIdentityPtyContext, evidence: EvidenceObservation): boolean { + const state = this.states.get(context.ptyId) + const activeRun = state?.activeRun + if (!state || state.incarnationId !== context.incarnationId || !activeRun) { + return false + } + return appendEvidenceToActiveRun(activeRun, evidence) + } + + observeTitle(context: PaneAgentIdentityPtyContext, agent: TuiAgent): void { + if (this.observeEvidence(context, { source: 'title', agent })) { + return + } + const state = this.getState(context) + if (state.ordinal > 0 || state.candidate || state.finalizedCandidateKey) { + return + } + const hostKind = titleHostKind(context.hostKinds) + const candidate = { + key: `${this.authorityId}:${context.ptyId}:${context.incarnationId ?? 'unknown'}:candidate`, + ptyId: context.ptyId, + hostKind + } + if (!this.scheduler.scheduleTitleCandidate(candidate.key, candidate.hostKind)) { + this.sink.addCoverage(hostKind, 'overflow') + state.finalizedCandidateKey = candidate.key + return + } + state.candidate = candidate + this.candidatesByKey.set(candidate.key, candidate) + } + + exitOrRebind(ptyId: string, incarnationId?: PtyIncarnationId | null): void { + const state = this.states.get(ptyId) + if (!state || (incarnationId !== undefined && state.incarnationId !== incarnationId)) { + return + } + if (state.activeRun && this.scheduler.isPendingRun(state.activeRun.key)) { + if (!this.scheduler.freezeRun(state.activeRun.key)) { + for (const hostKind of state.activeRun.hostKinds) { + this.sink.addCoverage(hostKind, 'overflow') + } + } + } + if (state.candidate && this.scheduler.isPendingCandidate(state.candidate.key)) { + if (!this.scheduler.freezeTitleCandidate(state.candidate.key, state.candidate.hostKind)) { + this.sink.addCoverage(state.candidate.hostKind, 'overflow') + } + } + if (state.activeRun) { + this.scheduler.exitOrRebind(state.activeRun.key) + this.runsByKey.delete(state.activeRun.key) + } + if (state.finalizedRunKey) { + this.scheduler.exitOrRebind(state.finalizedRunKey) + } + if (state.candidate) { + this.scheduler.exitOrRebind(state.candidate.key) + this.candidatesByKey.delete(state.candidate.key) + } + if (state.finalizedCandidateKey) { + this.scheduler.exitOrRebind(state.finalizedCandidateKey) + } + this.states.delete(ptyId) + } + + shutdown(): void { + for (const state of this.states.values()) { + if (state.activeRun && this.scheduler.isPendingRun(state.activeRun.key)) { + for (const hostKind of state.activeRun.hostKinds) { + this.sink.addCoverage(hostKind, 'truncated') + } + } + if (state.candidate && this.scheduler.isPendingCandidate(state.candidate.key)) { + this.sink.addCoverage(state.candidate.hostKind, 'truncated') + } + } + this.scheduler.shutdown() + this.states.clear() + this.runsByKey.clear() + this.candidatesByKey.clear() + } + + private getState(context: PaneAgentIdentityPtyContext): PtyObservationState { + const existing = this.states.get(context.ptyId) + if (existing && existing.incarnationId === context.incarnationId) { + return existing + } + if (existing) { + this.exitOrRebind(context.ptyId, existing.incarnationId) + } + const state: PtyObservationState = { + incarnationId: context.incarnationId, + ordinal: 0, + lastAttestationId: null, + activeRun: null, + finalizedRunKey: null, + candidate: null, + finalizedCandidateKey: null + } + this.states.set(context.ptyId, state) + return state + } + + private freezeRun(key: string): void { + const run = this.runsByKey.get(key) + if (!run) { + return + } + for (const hostKind of run.hostKinds) { + this.sink.record( + reducePaneAgentIdentityAvailability(hostKind, run.launchMode, { + facts: run.factsByHostKind.get(hostKind) ?? [], + currentRun: run.run, + owner: { agent: run.owner, run: run.run } + }) + ) + } + this.runsByKey.delete(key) + const state = this.states.get(run.ptyId) + if (state?.activeRun?.key === key) { + state.activeRun = null + state.finalizedRunKey = key + } + } + + private freezeCandidate(hostKind: PaneAgentIdentityHostKind, key: string): void { + this.sink.addCoverage(hostKind, 'candidate') + const candidate = this.candidatesByKey.get(key) + this.candidatesByKey.delete(key) + const candidateState = candidate ? this.states.get(candidate.ptyId) : undefined + if (candidateState) { + candidateState.candidate = null + candidateState.finalizedCandidateKey = key + } + } + + private recordFinalizedOverflow( + kind: 'run' | 'title-candidate', + hostKind: PaneAgentIdentityHostKind | undefined, + key: string + ): void { + if (kind === 'title-candidate' && hostKind) { + this.sink.addCoverage(hostKind, 'overflow') + const candidate = this.candidatesByKey.get(key) + this.candidatesByKey.delete(key) + const state = candidate ? this.states.get(candidate.ptyId) : undefined + if (state?.candidate?.key === key) { + state.candidate = null + state.finalizedCandidateKey = key + } + return + } + const run = this.runsByKey.get(key) + if (!run) { + return + } + for (const runHostKind of run.hostKinds) { + this.sink.addCoverage(runHostKind, 'overflow') + } + this.runsByKey.delete(key) + const state = this.states.get(run.ptyId) + if (state?.activeRun?.key === key) { + state.activeRun = null + } + } +} diff --git a/src/main/runtime/pane-agent-identity-observation-model.ts b/src/main/runtime/pane-agent-identity-observation-model.ts new file mode 100644 index 00000000000..a227d46ebbe --- /dev/null +++ b/src/main/runtime/pane-agent-identity-observation-model.ts @@ -0,0 +1,119 @@ +import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { TuiAgent } from '../../shared/tui-agent' +import type { + PaneAgentIdentityEvidenceFact, + PaneAgentIdentityHostKind, + PaneAgentIdentityLaunchMode +} from '../../shared/pane-agent-identity-evidence' +import type { + PaneAgentEvidenceSource, + PaneAgentRunKey +} from '../../shared/pane-agent-identity-resolver' + +export type ActiveRun = { + key: string + ptyId: string + run: PaneAgentRunKey + launchMode: PaneAgentIdentityLaunchMode + owner: TuiAgent + hostKinds: readonly PaneAgentIdentityHostKind[] + factsByHostKind: Map +} + +export type Candidate = { + key: string + ptyId: string + hostKind: PaneAgentIdentityHostKind +} + +export type PtyObservationState = { + incarnationId: PtyIncarnationId | null + ordinal: number + lastAttestationId: string | null + activeRun: ActiveRun | null + finalizedRunKey: string | null + candidate: Candidate | null + finalizedCandidateKey: string | null +} + +export type EvidenceObservation = + | { + source: 'process' + agent: TuiAgent + processProvenance: 'execution-host' | 'distro-origin' | 'target-origin' + } + | { + source: Exclude< + PaneAgentEvidenceSource, + 'launch' | 'sleeping-session' | 'sibling' | 'process' + > + agent: TuiAgent + processProvenance?: never + } + +export const uniqueHostKinds = ( + hostKinds: readonly PaneAgentIdentityHostKind[] +): PaneAgentIdentityHostKind[] => [...new Set(hostKinds)] + +export function appendEvidenceToActiveRun( + activeRun: ActiveRun, + evidence: EvidenceObservation +): boolean { + for (const hostKind of activeRun.hostKinds) { + if (!evidenceBelongsToHost(hostKind, evidence)) { + continue + } + const facts = activeRun.factsByHostKind.get(hostKind) + if (!facts) { + continue + } + const fact: PaneAgentIdentityEvidenceFact = + evidence.source === 'process' + ? { + source: evidence.source, + agent: evidence.agent, + run: activeRun.run, + executionHostProvenance: evidence.processProvenance + } + : { source: evidence.source, agent: evidence.agent, run: activeRun.run } + if ( + !facts.some((existing) => existing.source === fact.source && existing.agent === fact.agent) + ) { + facts.push(fact) + } + } + return true +} + +export function titleHostKind( + hostKinds: readonly PaneAgentIdentityHostKind[] +): PaneAgentIdentityHostKind { + if (hostKinds.includes('ssh')) { + return 'ssh' + } + if (hostKinds.includes('wsl-distro')) { + return 'wsl-distro' + } + if (hostKinds.includes('relay')) { + return 'relay' + } + return 'native' +} + +function evidenceBelongsToHost( + hostKind: PaneAgentIdentityHostKind, + evidence: EvidenceObservation +): boolean { + if (hostKind === 'wsl-host') { + return false + } + if (evidence.source !== 'process') { + return true + } + return ( + (hostKind === 'native' && evidence.processProvenance === 'execution-host') || + (hostKind === 'wsl-distro' && evidence.processProvenance === 'distro-origin') || + (hostKind === 'ssh' && evidence.processProvenance === 'target-origin') || + hostKind === 'relay' + ) +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 5aa570a92d3..171e1682c20 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -177,6 +177,27 @@ describe('sendTerminal absence gate for leaf-branch writes', () => { expect(write).toHaveBeenCalledWith(STALE_PTY_ID, 'ping') }) + it('observes a recognized terminal.send command only after its accepted provider write', async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await makeRuntimeWithLeafHandle({ + hasPty: (ptyId) => ptyId === STALE_PTY_ID + }) + + const send = runtime.sendTerminal(handle, { text: 'codex', enter: true }) + await vi.advanceTimersByTimeAsync(500) + await send + vi.advanceTimersByTime(5_000) + + expect((await runtime.listTerminals()).agentIdentityAvailability?.rows).toEqual([ + ['native', 'typed', 1, 0, 0, 0, 0] + ]) + runtime.shutdownPaneAgentIdentityCensus(false) + } finally { + vi.useRealTimers() + } + }) + it('reuses a proven-absent verdict across repeated sends instead of re-probing', async () => { const probe = vi.fn(async () => false) const { runtime, handle } = await makeRuntimeWithLeafHandle({ probePtyLiveness: probe }) diff --git a/src/main/telemetry/pane-agent-identity-census-composition.test.ts b/src/main/telemetry/pane-agent-identity-census-composition.test.ts new file mode 100644 index 00000000000..868ab2f2236 --- /dev/null +++ b/src/main/telemetry/pane-agent-identity-census-composition.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { PaneAgentIdentityAuthoritySnapshot } from '../../shared/pane-agent-identity-availability' +import type { + PaneAgentIdentityHostKind, + PaneAgentIdentityLaunchMode +} from '../../shared/pane-agent-identity-evidence' +import { track } from './client' +import { + createDesktopPaneAgentIdentityCensus, + createHeadlessPaneAgentIdentityCensus +} from './pane-agent-identity-census-composition' + +vi.mock('./client', () => ({ track: vi.fn() })) + +describe('pane identity census composition', () => { + it('constructs the desktop census as a telemetry emitter', () => { + const census = createDesktopPaneAgentIdentityCensus() + census.record({ + hostKind: 'native', + launchMode: 'typed', + sourceMask: 4, + identityNull: false, + ambiguousTopRank: false + }) + census.flush() + + expect(track).toHaveBeenCalledWith('pane_agent_identity_availability', { + rows: [expect.objectContaining({ kind: 'aggregate', host_kind: 'native' })] + }) + census.shutdown() + }) + + it('constructs the headless census as one snapshot-only relay authority', () => { + vi.mocked(track).mockClear() + const census = createHeadlessPaneAgentIdentityCensus() + census.record({ + hostKind: 'wsl-distro', + launchMode: 'resume', + sourceMask: 20, + identityNull: false, + ambiguousTopRank: false + }) + census.flush() + + expect(census.snapshot().rows).toEqual([['relay', 'resume', 1, 0, 0, 0, 0]]) + expect(track).not.toHaveBeenCalled() + census.shutdown() + }) + + it('keeps the maximum authority snapshot below the terminal-list wire budget', () => { + const snapshot = new PaneAgentIdentityAuthoritySnapshot('e'.repeat(128)) + const hostKinds: PaneAgentIdentityHostKind[] = [ + 'native', + 'wsl-host', + 'wsl-distro', + 'ssh', + 'relay' + ] + const launchModes: PaneAgentIdentityLaunchMode[] = ['typed', 'orca-launch', 'resume'] + for (const hostKind of hostKinds) { + for (const launchMode of launchModes) { + snapshot.record({ + hostKind, + launchMode, + sourceMask: 64, + identityNull: true, + ambiguousTopRank: true + }) + } + snapshot.recordCandidateCoverage(hostKind) + } + + expect(Buffer.byteLength(JSON.stringify(snapshot.snapshot()), 'utf8')).toBeLessThan(2_048) + }) + + it('installs the census and primary hook facts in both production entry points', () => { + const desktop = readFileSync(resolve(process.cwd(), 'src/main/index.ts'), 'utf8') + const headless = readFileSync(resolve(process.cwd(), 'src/main/orcad/orcad-entry.ts'), 'utf8') + + expect(desktop).toContain( + 'const paneAgentIdentityCensus = isServeMode\n ? createHeadlessPaneAgentIdentityCensus()\n : createDesktopPaneAgentIdentityCensus()' + ) + expect(headless).toContain( + 'const paneAgentIdentityCensus = createHeadlessPaneAgentIdentityCensus()' + ) + for (const source of [desktop, headless]) { + expect(source).toMatch( + /new OrcaRuntimeService\([\s\S]*?\{[\s\S]*?paneAgentIdentityCensus[,\s]/ + ) + } + expect(desktop).toContain('runtime?.observeAgentHookStatus(enriched)') + }) +}) diff --git a/src/main/telemetry/pane-agent-identity-census-composition.ts b/src/main/telemetry/pane-agent-identity-census-composition.ts new file mode 100644 index 00000000000..553db19baa7 --- /dev/null +++ b/src/main/telemetry/pane-agent-identity-census-composition.ts @@ -0,0 +1,9 @@ +import { PaneAgentIdentityCensus } from './pane-agent-identity-census' + +export function createDesktopPaneAgentIdentityCensus(): PaneAgentIdentityCensus { + return new PaneAgentIdentityCensus() +} + +export function createHeadlessPaneAgentIdentityCensus(): PaneAgentIdentityCensus { + return new PaneAgentIdentityCensus({ emit: null, snapshotHostKind: 'relay' }) +} diff --git a/src/main/telemetry/pane-agent-identity-census.test.ts b/src/main/telemetry/pane-agent-identity-census.test.ts new file mode 100644 index 00000000000..8b6a2642869 --- /dev/null +++ b/src/main/telemetry/pane-agent-identity-census.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PaneAgentIdentityCensus } from './pane-agent-identity-census' + +describe('PaneAgentIdentityCensus', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('schedules relay-only deltas and preserves relay coverage host attribution', () => { + vi.useFakeTimers() + const emit = vi.fn() + const census = new PaneAgentIdentityCensus(emit) + const row = ['relay', 'typed', 1, 0, 0, 0, 0] as const + + census.ingestRelaySnapshot('env', { epoch: 'e', revision: 1, rows: [row] }) + vi.advanceTimersByTime(300_000) + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'coverage', + host_kind: 'relay', + reason: 'baseline', + count: 1 + }) + ]) + ) + emit.mockClear() + + census.ingestRelaySnapshot('env', { + epoch: 'e', + revision: 2, + rows: [['relay', 'typed', 2, 0, 0, 0, 0]], + candidateCoverage: [['relay', 2]] + }) + expect(emit).not.toHaveBeenCalled() + expect(census.snapshot().candidateCoverage).toBeUndefined() + + vi.advanceTimersByTime(300_000) + + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'aggregate', + host_kind: 'relay', + attested_runs: 1 + }), + expect.objectContaining({ + kind: 'coverage', + host_kind: 'relay', + reason: 'snapshot', + count: 1 + }), + expect.objectContaining({ + kind: 'coverage', + host_kind: 'relay', + reason: 'candidate', + count: 2 + }) + ]) + ) + census.shutdown() + }) +}) diff --git a/src/main/telemetry/pane-agent-identity-census.ts b/src/main/telemetry/pane-agent-identity-census.ts new file mode 100644 index 00000000000..504aad364d9 --- /dev/null +++ b/src/main/telemetry/pane-agent-identity-census.ts @@ -0,0 +1,240 @@ +import { + aggregatePaneAgentIdentityAvailability, + emptyPaneAgentIdentityAggregate, + PaneAgentIdentityRelaySnapshotMerger, + PaneAgentIdentityAuthoritySnapshot, + PANE_AGENT_IDENTITY_MAX_BATCH_ROWS, + type PaneAgentIdentityAggregate, + type PaneAgentIdentityAvailabilitySnapshot +} from '../../shared/pane-agent-identity-availability' +import type { + PaneAgentIdentityAvailability, + PaneAgentIdentityHostKind +} from '../../shared/pane-agent-identity-evidence' +import { track } from './client' +import { randomUUID } from 'node:crypto' + +type CoverageReason = + | 'snapshot' + | 'baseline' + | 'epoch_changed' + | 'counter_regressed' + | 'capacity' + | 'candidate' + | 'overflow' + | 'truncated' +type AggregateRow = { + kind: 'aggregate' + host_kind: PaneAgentIdentityAggregate['hostKind'] + launch_mode: PaneAgentIdentityAggregate['launchMode'] + attested_runs: number + no_evidence: number + title_only: number + identity_null: number + ambiguous_top_rank: number +} +type CoverageRow = { + kind: 'coverage' + host_kind: PaneAgentIdentityAggregate['hostKind'] + reason: CoverageReason + count: number +} +type TelemetryRow = AggregateRow | CoverageRow +type TelemetryEmitter = (rows: readonly TelemetryRow[]) => void +type PaneAgentIdentityCensusOptions = { + emit: TelemetryEmitter | null + snapshotHostKind?: PaneAgentIdentityHostKind + epoch?: string +} + +const keyOf = (hostKind: string, launchMode: string): string => `${hostKind}:${launchMode}` + +export class PaneAgentIdentityCensus { + private readonly aggregate = new Map() + private readonly relay = new PaneAgentIdentityRelaySnapshotMerger() + private readonly coverage = new Map() + private timer: ReturnType | undefined + private closed = false + private readonly emit: TelemetryEmitter | null + private readonly snapshotHostKind: PaneAgentIdentityHostKind | undefined + private readonly authoritySnapshot: PaneAgentIdentityAuthoritySnapshot + + constructor(input: TelemetryEmitter | PaneAgentIdentityCensusOptions = defaultOptions()) { + const options = typeof input === 'function' ? { emit: input } : input + this.emit = options.emit + this.snapshotHostKind = options.snapshotHostKind + this.authoritySnapshot = new PaneAgentIdentityAuthoritySnapshot(options.epoch ?? randomUUID()) + } + + record(row: PaneAgentIdentityAvailability): void { + if (this.closed) { + return + } + const normalized = this.normalizeObservation(row) + this.authoritySnapshot.record(normalized) + if (!this.emit) { + return + } + const key = keyOf(normalized.hostKind, normalized.launchMode) + this.aggregate.set( + key, + aggregatePaneAgentIdentityAvailability( + normalized, + this.aggregate.get(key) ?? + emptyPaneAgentIdentityAggregate(normalized.hostKind, normalized.launchMode) + ) + ) + this.scheduleFlush() + } + + ingestRelaySnapshot( + environmentKey: string, + snapshot: PaneAgentIdentityAvailabilitySnapshot | undefined + ): void { + if (this.closed) { + return + } + const result = this.relay.merge(environmentKey, snapshot) + if (result.kind !== 'unavailable') { + this.addCoverage('relay', 'snapshot') + } + if (result.kind === 'delta') { + for (const row of result.rows) { + // Apply cumulative deltas without reclassifying every run. + const key = keyOf(row.hostKind, row.launchMode) + const previous = + this.aggregate.get(key) ?? emptyPaneAgentIdentityAggregate(row.hostKind, row.launchMode) + this.aggregate.set(key, { + ...previous, + attestedRuns: Math.min(1_000_000_000, previous.attestedRuns + row.attestedRuns), + noEvidence: Math.min(1_000_000_000, previous.noEvidence + row.noEvidence), + titleOnly: Math.min(1_000_000_000, previous.titleOnly + row.titleOnly), + identityNull: Math.min(1_000_000_000, previous.identityNull + row.identityNull), + ambiguousTopRank: Math.min( + 1_000_000_000, + previous.ambiguousTopRank + row.ambiguousTopRank + ) + }) + } + for (const candidate of result.candidateCoverage) { + this.addTelemetryCoverage(candidate.hostKind, 'candidate', candidate.exposures) + } + } else if (result.kind === 'baseline') { + this.addCoverage('relay', 'baseline') + } else if (result.kind === 'failed-closed') { + this.addCoverage( + 'relay', + result.reason === 'capacity' + ? 'capacity' + : result.reason === 'counter-regressed' + ? 'counter_regressed' + : 'epoch_changed' + ) + } + } + + addCoverage( + hostKind: PaneAgentIdentityAggregate['hostKind'], + reason: CoverageReason, + amount = 1 + ): void { + if (this.closed) { + return + } + const normalizedHostKind = this.snapshotHostKind ?? hostKind + if (reason === 'candidate') { + this.authoritySnapshot.recordCandidateCoverage(normalizedHostKind, amount) + } + this.addTelemetryCoverage(normalizedHostKind, reason, amount) + } + + private addTelemetryCoverage( + hostKind: PaneAgentIdentityAggregate['hostKind'], + reason: CoverageReason, + amount: number + ): void { + if (!this.emit) { + return + } + const key = `${hostKind}:${reason}` + const previous = this.coverage.get(key) + this.coverage.set(key, { + kind: 'coverage', + host_kind: hostKind, + reason, + count: Math.min(1_000_000_000, (previous?.count ?? 0) + Math.max(0, Math.floor(amount))) + }) + this.scheduleFlush() + } + + scheduleFlush(delayMs = 300_000): void { + if (this.closed || !this.emit || this.timer) { + return + } + this.timer = setTimeout(() => { + this.timer = undefined + this.flush() + }, delayMs) + this.timer.unref?.() + } + + flush(): void { + if (this.closed) { + return + } + const rows: TelemetryRow[] = [...this.aggregate.values()].map((row) => ({ + kind: 'aggregate', + host_kind: row.hostKind, + launch_mode: row.launchMode, + attested_runs: row.attestedRuns, + no_evidence: row.noEvidence, + title_only: row.titleOnly, + identity_null: row.identityNull, + ambiguous_top_rank: row.ambiguousTopRank + })) + for (const coverage of this.coverage.values()) { + rows.push(coverage) + } + if (rows.length === 0) { + return + } + for (let offset = 0; offset < rows.length; offset += PANE_AGENT_IDENTITY_MAX_BATCH_ROWS) { + this.emit?.(rows.slice(offset, offset + PANE_AGENT_IDENTITY_MAX_BATCH_ROWS)) + } + this.aggregate.clear() + this.coverage.clear() + } + + shutdown(): void { + this.closed = true + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = undefined + this.aggregate.clear() + this.coverage.clear() + this.relay.clear() + } + + snapshot(): PaneAgentIdentityAvailabilitySnapshot { + return this.authoritySnapshot.snapshot() + } + + hostKindsForObservation( + hostKinds: readonly PaneAgentIdentityHostKind[] + ): readonly PaneAgentIdentityHostKind[] { + return this.snapshotHostKind ? [this.snapshotHostKind] : hostKinds + } + + private normalizeObservation(row: PaneAgentIdentityAvailability): PaneAgentIdentityAvailability { + return this.snapshotHostKind ? { ...row, hostKind: this.snapshotHostKind } : row + } +} + +function defaultOptions(): PaneAgentIdentityCensusOptions { + return { + emit: (rows) => track('pane_agent_identity_availability', { rows: [...rows] }) + } +} + +export type PaneAgentIdentityTelemetryRow = TelemetryRow diff --git a/src/preload/index.ts b/src/preload/index.ts index d36b385d10a..93edd52c13c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -326,7 +326,10 @@ import type { LocalLogTailReadResult, LocalLogTailWatchArgs } from '../shared/local-log-tail-types' -import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' +import { + observeRuntimeEnvironmentTerminalListResponse, + subscribeRuntimeEnvironmentFromPreload +} from './runtime-environment-subscriptions' import type { RuntimeEnvironmentSubscriptionHandle } from './runtime-environment-subscriptions' import type { HostedReviewForBranchArgs } from '../shared/hosted-review' import type { ReadClipboardTextOptions } from '../shared/clipboard-text' @@ -4700,14 +4703,20 @@ const api = { ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), retryConnectionsNow: (): Promise => ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), - call: (args: { + call: async (args: { selector: string method: string params?: unknown timeoutMs?: number expectedEnvironmentPairingRevision?: number - }): Promise> => - ipcRenderer.invoke('runtimeEnvironments:call', args), + }): Promise> => { + const response = (await ipcRenderer.invoke( + 'runtimeEnvironments:call', + args + )) as RuntimeRpcResponse + observeRuntimeEnvironmentTerminalListResponse(ipcRenderer, args, response) + return response + }, subscribe: async ( args: { selector: string diff --git a/src/preload/runtime-environment-subscriptions.test.ts b/src/preload/runtime-environment-subscriptions.test.ts index 585b0f75fa9..eb595344398 100644 --- a/src/preload/runtime-environment-subscriptions.test.ts +++ b/src/preload/runtime-environment-subscriptions.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { subscribeRuntimeEnvironmentFromPreload } from './runtime-environment-subscriptions' +import { + observeRuntimeEnvironmentTerminalListResponse, + subscribeRuntimeEnvironmentFromPreload +} from './runtime-environment-subscriptions' function deferred(): { promise: Promise @@ -55,6 +58,96 @@ function dispatch(ipc: ReturnType, event: SubscriptionEvent): } describe('subscribeRuntimeEnvironmentFromPreload', () => { + it('observes one-shot terminal lists at the preload boundary and skips old peers', () => { + const ipc = createIpc() + const snapshot = { epoch: 'epoch', revision: 1, rows: [] } + + observeRuntimeEnvironmentTerminalListResponse( + ipc, + { selector: 'desk', method: 'terminal.list' }, + { + id: 'rpc-list', + ok: true, + result: { terminals: [], agentIdentityAvailability: snapshot }, + _meta: { runtimeId: 'rt' } + } + ) + + expect(ipc.invoke).toHaveBeenCalledWith('telemetry:ingestPaneAgentIdentityAvailability', { + environmentKey: 'desk', + snapshot + }) + + ipc.invoke.mockClear() + observeRuntimeEnvironmentTerminalListResponse( + ipc, + { selector: 'desk', method: 'terminal.list' }, + { + id: 'rpc-old-peer', + ok: true, + result: { terminals: [] }, + _meta: { runtimeId: 'rt' } + } + ) + observeRuntimeEnvironmentTerminalListResponse( + ipc, + { selector: 'desk', method: 'terminal.show' }, + { + id: 'rpc-other', + ok: true, + result: { agentIdentityAvailability: snapshot }, + _meta: { runtimeId: 'rt' } + } + ) + observeRuntimeEnvironmentTerminalListResponse( + ipc, + { selector: 'desk', method: 'terminal.list' }, + { + id: 'rpc-private-extra', + ok: true, + result: { agentIdentityAvailability: { ...snapshot, rawTitle: 'private' } }, + _meta: { runtimeId: 'rt' } + } + ) + expect(ipc.invoke).not.toHaveBeenCalled() + }) + + it('observes each successful terminal-list subscription response once', async () => { + const ipc = createIpc() + ipc.invoke.mockImplementation((channel: string) => + channel === 'runtimeEnvironments:subscribe' + ? Promise.resolve({ subscriptionId: 'sub-list', requestId: 'rpc-list' }) + : (Promise.resolve({}) as Promise) + ) + const onResponse = vi.fn() + const cleanup = await subscribeRuntimeEnvironmentFromPreload( + ipc, + { selector: 'desk', method: 'terminal.list' }, + { onResponse }, + () => 'sub-list' + ) + const snapshot = { epoch: 'epoch', revision: 1, rows: [] } + + dispatch(ipc, { + subscriptionId: 'sub-list', + type: 'response', + response: { + id: 'rpc-list', + ok: true, + result: { terminals: [], agentIdentityAvailability: snapshot }, + _meta: { runtimeId: 'rt' } + } + }) + + expect(onResponse).toHaveBeenCalledTimes(1) + expect(ipc.invoke).toHaveBeenCalledTimes(2) + expect(ipc.invoke).toHaveBeenLastCalledWith('telemetry:ingestPaneAgentIdentityAvailability', { + environmentKey: 'desk', + snapshot + }) + cleanup.unsubscribe() + }) + it('registers the subscription event listener before invoking main', async () => { const subscription = deferred<{ subscriptionId: string; requestId: string }>() const ipc = createIpc() diff --git a/src/preload/runtime-environment-subscriptions.ts b/src/preload/runtime-environment-subscriptions.ts index f9d46b467aa..bc7bd5278f5 100644 --- a/src/preload/runtime-environment-subscriptions.ts +++ b/src/preload/runtime-environment-subscriptions.ts @@ -1,4 +1,5 @@ import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' +import { isPaneAgentIdentityAvailabilitySnapshot } from '../shared/pane-agent-identity-availability-validator' type RuntimeEnvironmentSubscribeArgs = { selector: string @@ -38,6 +39,8 @@ type RuntimeEnvironmentSubscriptionIpc = { ) => void } +type RuntimeEnvironmentTerminalListObserverIpc = Pick + const SUBSCRIPTION_EVENT_CHANNEL = 'runtimeEnvironments:subscriptionEvent' // Why: each subscribe() previously attached its own channel listener that the @@ -55,6 +58,28 @@ const subscriptionDispatchers = new WeakMap< RuntimeEnvironmentSubscriptionDispatcher >() +export function observeRuntimeEnvironmentTerminalListResponse( + ipc: RuntimeEnvironmentTerminalListObserverIpc, + args: Pick, + response: RuntimeRpcResponse +): void { + if (args.method !== 'terminal.list' || !response.ok || !response.result) { + return + } + if (typeof response.result !== 'object') { + return + } + const snapshot = (response.result as { agentIdentityAvailability?: unknown }) + .agentIdentityAvailability + if (snapshot === undefined || !isPaneAgentIdentityAvailabilitySnapshot(snapshot)) { + return + } + void ipc.invoke('telemetry:ingestPaneAgentIdentityAvailability', { + environmentKey: args.selector, + snapshot + }) +} + function releaseIdleDispatcher( ipc: RuntimeEnvironmentSubscriptionIpc, callbacks: Map, @@ -132,7 +157,17 @@ export async function subscribeRuntimeEnvironmentFromPreload( // Why: streaming RPCs can emit their first frame before ipcMain.handle() // resolves, so the dispatcher must be routing this id before invoking. const dispatcher = getOrCreateDispatcher(ipc) - dispatcher.callbacks.set(subscriptionId, callbacks) + const observedCallbacks = + args.method === 'terminal.list' + ? { + ...callbacks, + onResponse: (response: RuntimeRpcResponse): void => { + observeRuntimeEnvironmentTerminalListResponse(ipc, args, response) + callbacks.onResponse(response) + } + } + : callbacks + dispatcher.callbacks.set(subscriptionId, observedCallbacks) const releaseCurrentSubscription = (): void => { releaseSubscription(ipc, dispatcher, subscriptionId) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts index 8ed19498c36..6a3691a85ff 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts @@ -9,12 +9,10 @@ import { registerAgentHookTerminalLifecycleHandler } from '../agent-hook-termina import type { AgentCompletionStatusSnapshot } from '../agent-completion-coordinator-types' import { resolveCompatibleAgentTypeForOwner } from '../../../../../shared/agent-title-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' -import { recognizeAgentProcessFromCommandLine } from '../../../../../shared/agent-process-recognition' +import { ManualAgentCommandTracker } from '../../../../../shared/manual-agent-command-tracker' import type { TuiAgent } from '../../../../../shared/tui-agent' import { isTuiAgent } from '../../../../../shared/tui-agent-config' -import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits' - import type { ConnectPanePtySession } from './connect-pane-pty-session' export function installCommandInferredPaneAgent(session: ConnectPanePtySession): void { @@ -22,22 +20,17 @@ export function installCommandInferredPaneAgent(session: ConnectPanePtySession): // shadowing the shell's current command line, for generic terminals where no // launch metadata exists. Consumed by getAuthoritativePaneAgent below. session.commandInferredPaneAgent = null - session.pendingShellCommandLine = '' - session.pendingShellCommandCursor = 0 + session.manualAgentCommandTracker = new ManualAgentCommandTracker() session.commandInferredPaneAgentGeneration = 0 - session.shellCommandInferenceSuspendedUntilCommandEnd = false session.startAcceptedInferredCommand = (_agent: TuiAgent): void => {} session.requestKnownWindowsShiftEnterReconfirmation = (): void => {} session.resetPendingShellCommandLine = (): void => { - session.pendingShellCommandLine = '' - session.pendingShellCommandCursor = 0 + session.manualAgentCommandTracker.reset() } - session.rememberCommandInferredPaneAgent = (): void => { - const commandLine = session.pendingShellCommandLine.trim() - session.resetPendingShellCommandLine() - const candidateAgent = commandLine - ? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null) - : null + session.cancelSuspendedShellCommandInference = (): void => { + session.manualAgentCommandTracker.cancelSuspendedInference() + } + session.rememberCommandInferredPaneAgent = (candidateAgent: TuiAgent): void => { const state = useAppStore.getState() const registeredLaunchAgent = state.agentLaunchConfigByPaneKey[session.cacheKey]?.identity.agentType @@ -69,93 +62,6 @@ export function installCommandInferredPaneAgent(session: ConnectPanePtySession): }, 0) }) } - session.appendPendingShellCommandInput = (text: string): void => { - const available = MANUAL_AGENT_COMMAND_MAX_CHARS - session.pendingShellCommandLine.length - if (available <= 0) { - session.shellCommandInferenceSuspendedUntilCommandEnd = true - return - } - const inserted = text.slice(0, available) - session.pendingShellCommandLine = - session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) + - inserted + - session.pendingShellCommandLine.slice(session.pendingShellCommandCursor) - session.pendingShellCommandCursor += inserted.length - if (inserted.length < text.length) { - session.shellCommandInferenceSuspendedUntilCommandEnd = true - } - } - session.deletePendingShellCommandWord = (): void => { - const beforeCursor = session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) - const afterCursor = session.pendingShellCommandLine.slice(session.pendingShellCommandCursor) - const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') - session.pendingShellCommandLine = nextBeforeCursor + afterCursor - session.pendingShellCommandCursor = nextBeforeCursor.length - } - session.cancelSuspendedShellCommandInference = (): void => { - if (!session.shellCommandInferenceSuspendedUntilCommandEnd) { - return - } - session.shellCommandInferenceSuspendedUntilCommandEnd = false - session.resetPendingShellCommandLine() - } - session.deletePendingShellCommandCharacter = (): void => { - if (session.pendingShellCommandCursor === 0) { - return - } - session.pendingShellCommandLine = - session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor - 1) + - session.pendingShellCommandLine.slice(session.pendingShellCommandCursor) - session.pendingShellCommandCursor -= 1 - } - session.deletePendingShellCommandCharacterAtCursor = (): void => { - if (session.pendingShellCommandCursor >= session.pendingShellCommandLine.length) { - return - } - session.pendingShellCommandLine = - session.pendingShellCommandLine.slice(0, session.pendingShellCommandCursor) + - session.pendingShellCommandLine.slice(session.pendingShellCommandCursor + 1) - } - session.movePendingShellCommandCursor = (delta: number): void => { - session.pendingShellCommandCursor = Math.min( - session.pendingShellCommandLine.length, - Math.max(0, session.pendingShellCommandCursor + delta) - ) - } - session.consumeShellCommandCsiSequence = (data: string, index: number): number | null => { - if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') { - return null - } - let cursor = index + 2 - while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { - cursor += 1 - } - const final = data[cursor] - if (!final || !/[~A-Za-z]/.test(final)) { - return null - } - const params = data.slice(index + 2, cursor) - // Why: only emulate a bare one-column move. Parameterized/modified cursor keys - // (e.g. Ctrl+Left `\x1b[1;5D` = word-jump) move the real cursor by more than - // one, so tracking them as ±1 would desync the shadow line — fall through to - // reset instead of silently corrupting the sampled command. - if (final === 'D' && params === '') { - session.movePendingShellCommandCursor(-1) - } else if (final === 'C' && params === '') { - session.movePendingShellCommandCursor(1) - } else if (final === 'H' || (final === '~' && params === '1')) { - session.pendingShellCommandCursor = 0 - } else if (final === 'F' || (final === '~' && params === '4')) { - session.pendingShellCommandCursor = session.pendingShellCommandLine.length - } else if (final === '~' && params === '3') { - session.deletePendingShellCommandCharacterAtCursor() - } else if (final === '~' && (params === '200' || params === '201')) { - // Bracketed paste wrappers are terminal framing, not shell command text. - } else { - session.resetPendingShellCommandLine() - } - return cursor + 1 - } session.getLivePaneAgentTitle = (): string | null => { const state = useAppStore.getState() const runtimeTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] diff --git a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts index 83cc0a0301b..5f44df005a5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts @@ -4,7 +4,6 @@ import type { AgentType } from '../../../../../shared/agent-status-types' import { AGENT_INTERRUPT_SETTLE_MS } from '../../../../../shared/agent-interrupt-intent' import { resolvePaneAgentOwner } from '../../../../../shared/pane-agent-owner' -import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits' import { CURSOR_AGENT_REATTACH_HEADER, terminalOwnsDomFocus, @@ -34,62 +33,10 @@ export function installShellCommandInference(session: ConnectPanePtySession): vo session.resetPendingShellCommandLine() return } - if (session.shellCommandInferenceSuspendedUntilCommandEnd) { - if (data.includes('\x03') || data.includes('\x15')) { - session.shellCommandInferenceSuspendedUntilCommandEnd = false - session.resetPendingShellCommandLine() - } - if (data.includes('\r') || data.includes('\n')) { - session.shellCommandInferenceSuspendedUntilCommandEnd = false - } - return - } - if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { - session.resetPendingShellCommandLine() - session.shellCommandInferenceSuspendedUntilCommandEnd = - !data.includes('\r') && !data.includes('\n') - return - } - for (let index = 0; index < data.length; index += 1) { - const char = data[index]! - if (char === '\r' || char === '\n') { - session.shellCommandInferenceSuspendedUntilCommandEnd = false - session.rememberCommandInferredPaneAgent() - if (session.commandInferredPaneAgent) { - return - } - continue - } - if (char === '\x7f' || char === '\b') { - session.deletePendingShellCommandCharacter() - continue - } - if (char === '\x17') { - session.deletePendingShellCommandWord() - continue - } - if (char === '\x03' || char === '\x15') { - session.resetPendingShellCommandLine() - continue - } - if (char === '\x1b') { - const nextIndex = session.consumeShellCommandCsiSequence(data, index) - if (nextIndex !== null) { - index = nextIndex - 1 - continue - } - session.resetPendingShellCommandLine() - continue - } - if (char < ' ') { - session.resetPendingShellCommandLine() - continue - } - if (char >= ' ') { - session.appendPendingShellCommandInput(char) - if (session.shellCommandInferenceSuspendedUntilCommandEnd) { - return - } + for (const candidate of session.manualAgentCommandTracker.ingest(data)) { + session.rememberCommandInferredPaneAgent(candidate) + if (session.commandInferredPaneAgent) { + return } } } diff --git a/src/shared/manual-agent-command-tracker.ts b/src/shared/manual-agent-command-tracker.ts new file mode 100644 index 00000000000..217cc0b1397 --- /dev/null +++ b/src/shared/manual-agent-command-tracker.ts @@ -0,0 +1,125 @@ +import { recognizeAgentProcessFromCommandLine } from './agent-process-recognition' +import type { TuiAgent } from './tui-agent' + +export const MANUAL_AGENT_COMMAND_MAX_CHARS = 4_096 + +/** Bounded shell-line shadow used by both UI ownership inference and execution authorities. */ +export class ManualAgentCommandTracker { + private line = '' + private cursor = 0 + private suspended = false + private acceptedCount = 0 + + ingest(data: string): TuiAgent[] { + const accepted: TuiAgent[] = [] + if (this.suspended) { + if (data.includes('\x03') || data.includes('\x15')) { + this.reset() + } else if (data.includes('\r') || data.includes('\n')) { + this.suspended = false + } + return accepted + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + this.reset() + this.suspended = !data.includes('\r') && !data.includes('\n') + return accepted + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + if (!this.suspended) { + const recognized = recognizeAgentProcessFromCommandLine(this.line.trim()) + if (recognized) { + accepted.push(recognized.agent) + this.acceptedCount += 1 + } + } + this.reset() + continue + } + if (char === '\x7f' || char === '\b') { + this.backspace() + continue + } + if (char === '\x03' || char === '\x15') { + this.reset() + continue + } + if (char === '\x17') { + this.deleteWord() + continue + } + if (char === '\x1b' && data[index + 1] === '[') { + const nextIndex = this.consumeCsi(data, index) + if (nextIndex === null) { + this.reset() + } else { + index = nextIndex - 1 + } + continue + } + if (char < ' ') { + this.reset() + continue + } + if (this.line.length >= MANUAL_AGENT_COMMAND_MAX_CHARS) { + this.suspended = true + return accepted + } + this.line = this.line.slice(0, this.cursor) + char + this.line.slice(this.cursor) + this.cursor += 1 + } + return accepted + } + + reset(): void { + this.line = '' + this.cursor = 0 + this.suspended = false + } + cancelSuspendedInference(): void { + if (this.suspended) { + this.reset() + } + } + get acceptedCommandCount(): number { + return this.acceptedCount + } + private backspace(): void { + if (this.cursor > 0) { + this.line = this.line.slice(0, this.cursor - 1) + this.line.slice(this.cursor) + this.cursor -= 1 + } + } + private deleteWord(): void { + const before = this.line.slice(0, this.cursor).replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '') + this.line = before + this.line.slice(this.cursor) + this.cursor = before.length + } + private consumeCsi(data: string, index: number): number | null { + let cursor = index + 2 + while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) { + cursor += 1 + } + const final = data[cursor] + if (!final || !/[~A-Za-z]/.test(final)) { + return null + } + const params = data.slice(index + 2, cursor) + if (final === 'D' && params === '') { + this.cursor = Math.max(0, this.cursor - 1) + } else if (final === 'C' && params === '') { + this.cursor = Math.min(this.line.length, this.cursor + 1) + } else if (final === 'H' || (final === '~' && params === '1')) { + this.cursor = 0 + } else if (final === 'F' || (final === '~' && params === '4')) { + this.cursor = this.line.length + } else if (final === '~' && params === '3' && this.cursor < this.line.length) { + this.line = this.line.slice(0, this.cursor) + this.line.slice(this.cursor + 1) + } else if (final !== '~' || (params !== '200' && params !== '201')) { + this.reset() + } + return cursor + 1 + } +} diff --git a/src/shared/pane-agent-identity-availability-validator.ts b/src/shared/pane-agent-identity-availability-validator.ts new file mode 100644 index 00000000000..54a0f5f77fe --- /dev/null +++ b/src/shared/pane-agent-identity-availability-validator.ts @@ -0,0 +1,88 @@ +import { + PANE_AGENT_IDENTITY_MAX_COUNT, + type PaneAgentIdentityAvailabilitySnapshot +} from './pane-agent-identity-availability' + +export function isPaneAgentIdentityAvailabilitySnapshot( + value: unknown +): value is PaneAgentIdentityAvailabilitySnapshot { + if (!value || typeof value !== 'object') { + return false + } + const candidate = value as Record + const snapshotKeys = new Set(['epoch', 'revision', 'rows', 'candidateCoverage']) + if (Object.keys(candidate).some((key) => !snapshotKeys.has(key))) { + return false + } + if ( + typeof candidate.epoch !== 'string' || + candidate.epoch.length === 0 || + candidate.epoch.length > 128 + ) { + return false + } + if (!Number.isSafeInteger(candidate.revision) || (candidate.revision as number) < 0) { + return false + } + if (!Array.isArray(candidate.rows) || candidate.rows.length > 15) { + return false + } + const rowKeys = new Set() + const rowsValid = candidate.rows.every((row) => { + if (!Array.isArray(row) || row.length !== 7) { + return false + } + const [hostKind, launchMode, attestedRuns, ...counters] = row + if ( + !['native', 'wsl-host', 'wsl-distro', 'ssh', 'relay'].includes(hostKind as string) || + !['typed', 'orca-launch', 'resume'].includes(launchMode as string) + ) { + return false + } + const key = `${hostKind as string}:${launchMode as string}` + if (rowKeys.has(key)) { + return false + } + rowKeys.add(key) + if ( + !Number.isSafeInteger(attestedRuns) || + (attestedRuns as number) < 1 || + (attestedRuns as number) > PANE_AGENT_IDENTITY_MAX_COUNT + ) { + return false + } + return counters.every( + (count) => + Number.isSafeInteger(count) && + (count as number) >= 0 && + (count as number) <= (attestedRuns as number) + ) + }) + if (!rowsValid) { + return false + } + if (candidate.candidateCoverage === undefined) { + return true + } + if (!Array.isArray(candidate.candidateCoverage) || candidate.candidateCoverage.length > 5) { + return false + } + const coverageHosts = new Set() + return candidate.candidateCoverage.every((row) => { + if (!Array.isArray(row) || row.length !== 2) { + return false + } + const [hostKind, exposures] = row + if ( + !['native', 'wsl-host', 'wsl-distro', 'ssh', 'relay'].includes(hostKind as string) || + !Number.isSafeInteger(exposures) || + (exposures as number) < 1 || + (exposures as number) > PANE_AGENT_IDENTITY_MAX_COUNT || + coverageHosts.has(hostKind as string) + ) { + return false + } + coverageHosts.add(hostKind as string) + return true + }) +} diff --git a/src/shared/pane-agent-identity-availability.ts b/src/shared/pane-agent-identity-availability.ts new file mode 100644 index 00000000000..2f9f212c9af --- /dev/null +++ b/src/shared/pane-agent-identity-availability.ts @@ -0,0 +1,275 @@ +import type { + PaneAgentIdentityAvailability, + PaneAgentIdentityHostKind, + PaneAgentIdentityLaunchMode +} from './pane-agent-identity-evidence' + +export const PANE_AGENT_IDENTITY_MAX_COUNT = 1_000_000_000 +export const PANE_AGENT_IDENTITY_MAX_BATCH_ROWS = 64 +export const PANE_AGENT_IDENTITY_MAX_REMOTE_ENVIRONMENTS = 256 +export { + MAX_LIVE_FINALIZED_RUN_KEYS, + MAX_PENDING_IDENTITY_OBSERVATIONS, + PaneAgentIdentityObservationScheduler, + RUN_SETTLE_MS, + TITLE_CANDIDATE_WINDOW_MS, + type PaneAgentIdentityObservationSchedulerDeps +} from './pane-agent-identity-observation-scheduler' + +export type PaneAgentIdentityAggregate = { + hostKind: PaneAgentIdentityHostKind + launchMode: PaneAgentIdentityLaunchMode + attestedRuns: number + noEvidence: number + titleOnly: number + identityNull: number + ambiguousTopRank: number +} + +export type PaneAgentIdentityCandidateCoverage = { + hostKind: PaneAgentIdentityHostKind + exposures: number +} + +export type PaneAgentIdentityAggregateWireRow = readonly [ + hostKind: PaneAgentIdentityHostKind, + launchMode: PaneAgentIdentityLaunchMode, + attestedRuns: number, + noEvidence: number, + titleOnly: number, + identityNull: number, + ambiguousTopRank: number +] + +export type PaneAgentIdentityCandidateCoverageWireRow = readonly [ + hostKind: PaneAgentIdentityHostKind, + exposures: number +] + +export type PaneAgentIdentityAvailabilitySnapshot = { + epoch: string + revision: number + /** Compact fixed tuples keep the cumulative field below the terminal-list wire budget. */ + rows: readonly PaneAgentIdentityAggregateWireRow[] + candidateCoverage?: readonly PaneAgentIdentityCandidateCoverageWireRow[] +} + +export function emptyPaneAgentIdentityAggregate( + hostKind: PaneAgentIdentityHostKind, + launchMode: PaneAgentIdentityLaunchMode +): PaneAgentIdentityAggregate { + return { + hostKind, + launchMode, + attestedRuns: 0, + noEvidence: 0, + titleOnly: 0, + identityNull: 0, + ambiguousTopRank: 0 + } +} + +function addCount(value: number, amount: number): number { + return Math.min(PANE_AGENT_IDENTITY_MAX_COUNT, value + Math.max(0, Math.floor(amount))) +} + +export function aggregatePaneAgentIdentityAvailability( + row: PaneAgentIdentityAvailability, + aggregate = emptyPaneAgentIdentityAggregate(row.hostKind, row.launchMode) +): PaneAgentIdentityAggregate { + const titleOnly = row.sourceMask === 64 + return { + ...aggregate, + attestedRuns: addCount(aggregate.attestedRuns, 1), + noEvidence: addCount(aggregate.noEvidence, row.sourceMask === 0 ? 1 : 0), + titleOnly: addCount(aggregate.titleOnly, titleOnly ? 1 : 0), + identityNull: addCount(aggregate.identityNull, row.identityNull ? 1 : 0), + ambiguousTopRank: addCount(aggregate.ambiguousTopRank, row.ambiguousTopRank ? 1 : 0) + } +} + +export type RelayMergeResult = + | { kind: 'baseline' } + | { + kind: 'delta' + rows: PaneAgentIdentityAggregate[] + candidateCoverage: PaneAgentIdentityCandidateCoverage[] + } + | { kind: 'stale' } + | { + kind: 'failed-closed' + reason: 'epoch-changed' | 'counter-regressed' | 'capacity' + } + | { kind: 'unavailable' } + +type RelayState = { + epoch: string + revision: number + rows: Map + candidates: Map + failedClosedReason: 'epoch-changed' | 'counter-regressed' | null +} + +const rowKey = (row: PaneAgentIdentityAggregate): string => `${row.hostKind}:${row.launchMode}` +const COUNTER_KEYS = [ + 'attestedRuns', + 'noEvidence', + 'titleOnly', + 'identityNull', + 'ambiguousTopRank' +] as const + +const decodeAggregateRow = ( + row: PaneAgentIdentityAggregateWireRow +): PaneAgentIdentityAggregate => ({ + hostKind: row[0], + launchMode: row[1], + attestedRuns: row[2], + noEvidence: row[3], + titleOnly: row[4], + identityNull: row[5], + ambiguousTopRank: row[6] +}) + +/** Session-scoped cumulative snapshot merger. State is intentionally not evicted. */ +export class PaneAgentIdentityRelaySnapshotMerger { + private readonly states = new Map() + private readonly maxEnvironments: number + constructor(maxEnvironments = PANE_AGENT_IDENTITY_MAX_REMOTE_ENVIRONMENTS) { + this.maxEnvironments = maxEnvironments + } + + merge( + environmentKey: string, + snapshot: PaneAgentIdentityAvailabilitySnapshot | undefined + ): RelayMergeResult { + if ( + !snapshot || + typeof snapshot.epoch !== 'string' || + !Number.isSafeInteger(snapshot.revision) || + snapshot.revision < 0 + ) { + return { kind: 'unavailable' } + } + let state = this.states.get(environmentKey) + const decodedRows = snapshot.rows.map(decodeAggregateRow) + if (!state) { + if (this.states.size >= this.maxEnvironments) { + return { kind: 'failed-closed', reason: 'capacity' } + } + state = { + epoch: snapshot.epoch, + revision: snapshot.revision, + rows: new Map(decodedRows.map((row) => [rowKey(row), row])), + candidates: new Map((snapshot.candidateCoverage ?? []).map((row) => [row[0], row[1]])), + failedClosedReason: null + } + this.states.set(environmentKey, state) + return { kind: 'baseline' } + } + if (state.failedClosedReason) { + return { kind: 'failed-closed', reason: state.failedClosedReason } + } + if (state.epoch !== snapshot.epoch) { + state.failedClosedReason = 'epoch-changed' + return { kind: 'failed-closed', reason: 'epoch-changed' } + } + if (snapshot.revision <= state.revision) { + return { kind: 'stale' } + } + const incomingRows = new Map(decodedRows.map((row) => [rowKey(row), row])) + const incomingCandidates = new Map( + (snapshot.candidateCoverage ?? []).map((row) => [row[0], row[1]]) + ) + const counterRegressed = + [...state.rows].some(([key, previous]) => { + const incoming = incomingRows.get(key) + return !incoming || COUNTER_KEYS.some((counter) => incoming[counter] < previous[counter]) + }) || + [...state.candidates].some( + ([hostKind, previous]) => (incomingCandidates.get(hostKind) ?? -1) < previous + ) + if (counterRegressed) { + state.failedClosedReason = 'counter-regressed' + return { kind: 'failed-closed', reason: 'counter-regressed' } + } + const deltas: PaneAgentIdentityAggregate[] = [] + for (const incoming of decodedRows) { + const previous = + state.rows.get(rowKey(incoming)) ?? + emptyPaneAgentIdentityAggregate(incoming.hostKind, incoming.launchMode) + const delta = { + ...incoming, + ...Object.fromEntries( + COUNTER_KEYS.map((key) => [key, Math.max(0, incoming[key] - previous[key])]) + ) + } as PaneAgentIdentityAggregate + if (COUNTER_KEYS.some((key) => delta[key] > 0)) { + deltas.push(delta) + } + state.rows.set(rowKey(incoming), { ...incoming }) + } + const candidateCoverage: PaneAgentIdentityCandidateCoverage[] = [] + for (const [hostKind, exposures] of incomingCandidates) { + const delta = exposures - (state.candidates.get(hostKind) ?? 0) + if (delta > 0) { + candidateCoverage.push({ hostKind, exposures: delta }) + } + state.candidates.set(hostKind, exposures) + } + state.revision = snapshot.revision + return { kind: 'delta', rows: deltas, candidateCoverage } + } + + clear(): void { + this.states.clear() + } + get size(): number { + return this.states.size + } +} + +export class PaneAgentIdentityAuthoritySnapshot { + private readonly rows = new Map() + private readonly candidates = new Map() + private revision = 0 + constructor(private readonly epoch: string) {} + + record(observation: PaneAgentIdentityAvailability): void { + const key = `${observation.hostKind}:${observation.launchMode}` + this.rows.set( + key, + aggregatePaneAgentIdentityAvailability( + observation, + this.rows.get(key) ?? + emptyPaneAgentIdentityAggregate(observation.hostKind, observation.launchMode) + ) + ) + this.revision += 1 + } + + recordCandidateCoverage(hostKind: PaneAgentIdentityHostKind, amount = 1): void { + this.candidates.set(hostKind, addCount(this.candidates.get(hostKind) ?? 0, amount)) + this.revision += 1 + } + + snapshot(): PaneAgentIdentityAvailabilitySnapshot { + const candidateCoverage: PaneAgentIdentityCandidateCoverageWireRow[] = [...this.candidates] + return { + epoch: this.epoch, + revision: this.revision, + rows: [...this.rows.values()].map( + (row): PaneAgentIdentityAggregateWireRow => [ + row.hostKind, + row.launchMode, + row.attestedRuns, + row.noEvidence, + row.titleOnly, + row.identityNull, + row.ambiguousTopRank + ] + ), + ...(candidateCoverage.length > 0 ? { candidateCoverage } : {}) + } + } +} diff --git a/src/shared/pane-agent-identity-evidence.test.ts b/src/shared/pane-agent-identity-evidence.test.ts new file mode 100644 index 00000000000..32f627b32e5 --- /dev/null +++ b/src/shared/pane-agent-identity-evidence.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest' +import { + assemblePaneAgentIdentityEvidence, + reducePaneAgentIdentityAvailability +} from './pane-agent-identity-evidence' +import { + aggregatePaneAgentIdentityAvailability, + PaneAgentIdentityRelaySnapshotMerger +} from './pane-agent-identity-availability' +import { isPaneAgentIdentityAvailabilitySnapshot } from './pane-agent-identity-availability-validator' +import { + MANUAL_AGENT_COMMAND_MAX_CHARS, + ManualAgentCommandTracker +} from './manual-agent-command-tracker' +import { eventSchemas } from './telemetry-events' + +describe('pane identity evidence census', () => { + it('returns eligible evidence in the canonical resolver order', () => { + expect( + assemblePaneAgentIdentityEvidence({ + facts: [ + { source: 'title', agent: 'codex' }, + { source: 'completed-hook', agent: 'codex' }, + { + source: 'process', + agent: 'codex', + executionHostProvenance: 'execution-host' + }, + { source: 'live-hook', agent: 'codex' } + ] + }).evidence.map((fact) => fact.source) + ).toEqual(['live-hook', 'process', 'completed-hook', 'title']) + }) + + it('drops process facts without positive provenance and emits fixed source bits', () => { + const result = assemblePaneAgentIdentityEvidence({ + facts: [ + { source: 'process', agent: 'codex', executionHostProvenance: undefined } as never, + { source: 'title', agent: 'codex' } + ] + }) + expect(result.sourceMask).toBe(64) + expect(result.identityNull).toBe(false) + }) + + it('reduces no evidence and ambiguity without exposing the winning agent', () => { + expect(reducePaneAgentIdentityAvailability('native', 'typed', { facts: [] })).toMatchObject({ + sourceMask: 0, + identityNull: true + }) + const ambiguous = reducePaneAgentIdentityAvailability('native', 'orca-launch', { + facts: [ + { source: 'live-hook', agent: 'codex' }, + { source: 'live-hook', agent: 'claude' } + ] + }) + expect(ambiguous.ambiguousTopRank).toBe(true) + expect(ambiguous).not.toHaveProperty('resolvedAgent') + expect( + aggregatePaneAgentIdentityAvailability({ + hostKind: 'native', + launchMode: 'typed', + sourceMask: 64, + identityNull: false, + ambiguousTopRank: false + }).titleOnly + ).toBe(1) + }) + + it('excludes superseded and pane-ineligible evidence from the availability mask', () => { + const currentRun = { authorityId: 'authority', incarnation: 2 } + const result = reducePaneAgentIdentityAvailability('native', 'orca-launch', { + currentRun, + facts: [ + { + source: 'live-hook', + agent: 'claude', + run: { authorityId: 'authority', incarnation: 1 } + }, + { source: 'sibling', agent: 'claude', run: currentRun }, + { source: 'title', agent: 'codex', run: currentRun } + ] + }) + + expect(result).toEqual({ + hostKind: 'native', + launchMode: 'orca-launch', + sourceMask: 64, + identityNull: false, + ambiguousTopRank: false + }) + }) + + it('uses same-run owner compatibility and preserves relay baselines', () => { + const key = { authorityId: 'a', incarnation: 1 } + expect( + assemblePaneAgentIdentityEvidence({ + currentRun: key, + owner: { agent: 'omp', run: key }, + facts: [{ source: 'launch', agent: 'pi', run: key }] + }).resolvedAgent + ).toBe('omp') + expect( + assemblePaneAgentIdentityEvidence({ + currentRun: key, + owner: { agent: 'claude', run: key }, + facts: [{ source: 'launch', agent: 'codex', run: key }] + }).resolvedAgent + ).toBe('codex') + const merger = new PaneAgentIdentityRelaySnapshotMerger() + const snapshot = { + epoch: 'e', + revision: 1, + rows: [['relay', 'typed', 1, 1, 0, 1, 0] as const] + } + expect(merger.merge('env', snapshot)).toEqual({ kind: 'baseline' }) + expect(merger.merge('env', { ...snapshot, revision: 0 })).toEqual({ kind: 'stale' }) + expect( + merger.merge('env', { + ...snapshot, + revision: 2, + rows: [['relay', 'typed', 2, 1, 0, 1, 0] as const] + }) + ).toMatchObject({ kind: 'delta' }) + expect(merger.merge('env', { ...snapshot, revision: 3, epoch: 'new' })).toEqual({ + kind: 'failed-closed', + reason: 'epoch-changed' + }) + }) + + it('fail-closes cumulative counter regressions without moving the baseline backward', () => { + const merger = new PaneAgentIdentityRelaySnapshotMerger() + const row = ['relay', 'typed', 5, 1, 0, 1, 0] as const + expect(merger.merge('env', { epoch: 'e', revision: 1, rows: [row] })).toEqual({ + kind: 'baseline' + }) + expect( + merger.merge('env', { + epoch: 'e', + revision: 2, + rows: [['relay', 'typed', 4, 1, 0, 1, 0]] + }) + ).toEqual({ kind: 'failed-closed', reason: 'counter-regressed' }) + expect( + merger.merge('env', { + epoch: 'e', + revision: 3, + rows: [['relay', 'typed', 6, 1, 0, 1, 0]] + }) + ).toEqual({ kind: 'failed-closed', reason: 'counter-regressed' }) + }) + + it('merges candidate coverage as a cumulative delta and fail-closes regressions', () => { + const merger = new PaneAgentIdentityRelaySnapshotMerger() + const baseline = { + epoch: 'e', + revision: 1, + rows: [], + candidateCoverage: [['relay', 2] as const] + } + expect(merger.merge('env', baseline)).toEqual({ kind: 'baseline' }) + expect( + merger.merge('env', { + ...baseline, + revision: 2, + candidateCoverage: [['relay', 5] as const] + }) + ).toEqual({ + kind: 'delta', + rows: [], + candidateCoverage: [{ hostKind: 'relay', exposures: 3 }] + }) + expect( + merger.merge('env', { + ...baseline, + revision: 3, + candidateCoverage: [['relay', 4] as const] + }) + ).toEqual({ kind: 'failed-closed', reason: 'counter-regressed' }) + }) + + it('strictly validates privacy-reduced snapshots and optional candidate coverage', () => { + const snapshot = { + epoch: 'e', + revision: 1, + rows: [['relay', 'typed', 1, 0, 0, 0, 0] as const], + candidateCoverage: [['ssh', 1] as const] + } + expect(isPaneAgentIdentityAvailabilitySnapshot(snapshot)).toBe(true) + expect( + isPaneAgentIdentityAvailabilitySnapshot({ ...snapshot, rawTitle: 'private title' }) + ).toBe(false) + expect( + isPaneAgentIdentityAvailabilitySnapshot({ + ...snapshot, + candidateCoverage: [['ssh', 1, '/private']] + }) + ).toBe(false) + expect( + isPaneAgentIdentityAvailabilitySnapshot({ + ...snapshot, + rows: [['relay', 'typed', 1, 2, 0, 0, 0]] + }) + ).toBe(false) + }) + + it('accepts only bounded enum-and-count telemetry rows', () => { + const valid = { + rows: [ + { + kind: 'coverage' as const, + host_kind: 'relay' as const, + reason: 'counter_regressed' as const, + count: 1 + } + ] + } + expect(eventSchemas.pane_agent_identity_availability.safeParse(valid).success).toBe(true) + expect( + eventSchemas.pane_agent_identity_availability.safeParse({ + rows: [{ ...valid.rows[0], title: 'private' }] + }).success + ).toBe(false) + expect( + eventSchemas.pane_agent_identity_availability.safeParse({ + rows: [{ ...valid.rows[0], count: '1' }] + }).success + ).toBe(false) + }) + + it('retains bounded command edit semantics and discards input after classification', () => { + const tracker = new ManualAgentCommandTracker() + expect(tracker.ingest('codex\r')).toEqual(['codex']) + expect(tracker.ingest('cod\u0008dex\r')).toEqual(['codex']) + expect(tracker.ingest('claude\rcodex\r')).toEqual(['claude', 'codex']) + + tracker.ingest('x'.repeat(MANUAL_AGENT_COMMAND_MAX_CHARS)) + expect(tracker.ingest('y\rcodex\r')).toEqual([]) + expect(tracker.ingest('\r')).toEqual([]) + expect(tracker.ingest('codex\r')).toEqual([]) + tracker.ingest('\x15') + expect(tracker.ingest('codex\r')).toEqual(['codex']) + }) + + it('only clears a pending command when cancelling suspended inference', () => { + const tracker = new ManualAgentCommandTracker() + tracker.ingest('co') + tracker.cancelSuspendedInference() + expect(tracker.ingest('dex\r')).toEqual(['codex']) + + tracker.ingest('x'.repeat(MANUAL_AGENT_COMMAND_MAX_CHARS + 1)) + tracker.cancelSuspendedInference() + expect(tracker.ingest('claude\r')).toEqual(['claude']) + }) +}) diff --git a/src/shared/pane-agent-identity-evidence.ts b/src/shared/pane-agent-identity-evidence.ts new file mode 100644 index 00000000000..34dea293aec --- /dev/null +++ b/src/shared/pane-agent-identity-evidence.ts @@ -0,0 +1,139 @@ +import { + PANE_AGENT_EVIDENCE_SOURCES, + type PaneAgentEvidence, + type PaneAgentEvidenceSource, + type PaneAgentRunKey, + resolvePaneAgentIdentity +} from './pane-agent-identity-resolver' +import { resolveCompatibleAgentTypeForOwner } from './agent-title-owner' +import type { TuiAgent } from './tui-agent' + +export type PaneAgentIdentityHostKind = 'native' | 'wsl-host' | 'wsl-distro' | 'ssh' | 'relay' +export type PaneAgentIdentityLaunchMode = 'typed' | 'orca-launch' | 'resume' + +type PaneAgentIdentityNonProcessFact = Omit, 'source'> & { + source: Exclude + executionHostProvenance?: never +} +type PaneAgentIdentityProcessFact = Omit, 'source'> & { + source: 'process' + /** Process observations must identify the execution host that produced them. */ + executionHostProvenance: 'execution-host' | 'distro-origin' | 'target-origin' +} +export type PaneAgentIdentityEvidenceFact = + | PaneAgentIdentityNonProcessFact + | PaneAgentIdentityProcessFact + +export type PaneAgentIdentityOwnerFact = { + agent: TuiAgent + run: PaneAgentRunKey +} + +export type PaneAgentIdentityEvidenceInput = { + facts: readonly PaneAgentIdentityEvidenceFact[] + currentRun?: PaneAgentRunKey + owner?: PaneAgentIdentityOwnerFact +} + +/** Stable encoding. Do not derive telemetry bits from array indexes. */ +export const PANE_AGENT_IDENTITY_SOURCE_BITS: Readonly> = { + 'live-hook': 1, + process: 2, + launch: 4, + 'completed-hook': 8, + 'sleeping-session': 16, + sibling: 32, + title: 64 +} + +export type PaneAgentIdentityEvidence = { + evidence: readonly PaneAgentEvidence[] + resolvedAgent: TuiAgent | null + sourceMask: number + identityNull: boolean + ambiguousTopRank: boolean +} + +/** Assemble already-redacted authority facts; raw titles/commands never enter this layer. */ +export function assemblePaneAgentIdentityEvidence( + input: PaneAgentIdentityEvidenceInput +): PaneAgentIdentityEvidence { + const evidence = input.facts + .filter((fact) => { + if (fact.source === 'process' && fact.executionHostProvenance === undefined) { + return false + } + if (fact.source === 'sibling') { + return false + } + if ( + input.currentRun && + fact.run?.authorityId === input.currentRun.authorityId && + fact.run.incarnation !== input.currentRun.incarnation + ) { + return false + } + return true + }) + .sort( + (left, right) => + PANE_AGENT_EVIDENCE_SOURCES.indexOf(left.source) - + PANE_AGENT_EVIDENCE_SOURCES.indexOf(right.source) + ) + const resolved = resolvePaneAgentIdentity({ + evidence, + currentRun: input.currentRun + }) + let resolvedAgent = resolved.agent + if ( + resolvedAgent && + input.owner && + input.currentRun && + input.owner.run.authorityId === input.currentRun.authorityId && + input.owner.run.incarnation === input.currentRun.incarnation + ) { + // Owner compatibility is deliberately same-run only; incomparable owners do not rewrite. + if ( + input.owner.agent !== resolvedAgent && + resolveCompatibleAgentTypeForOwner(resolvedAgent, input.owner.agent) === input.owner.agent + ) { + resolvedAgent = input.owner.agent + } + } + return { + evidence, + resolvedAgent, + sourceMask: evidence.reduce( + (mask, item) => mask | PANE_AGENT_IDENTITY_SOURCE_BITS[item.source], + 0 + ), + identityNull: resolved.agent === null, + ambiguousTopRank: resolved.ambiguousAt !== undefined + } +} + +export type PaneAgentIdentityAvailability = { + hostKind: PaneAgentIdentityHostKind + launchMode: PaneAgentIdentityLaunchMode + sourceMask: number + identityNull: boolean + ambiguousTopRank: boolean +} + +export function reducePaneAgentIdentityAvailability( + hostKind: PaneAgentIdentityHostKind, + launchMode: PaneAgentIdentityLaunchMode, + input: PaneAgentIdentityEvidenceInput +): PaneAgentIdentityAvailability { + const assembled = assemblePaneAgentIdentityEvidence(input) + return { + hostKind, + launchMode, + sourceMask: assembled.sourceMask, + identityNull: assembled.identityNull, + ambiguousTopRank: assembled.ambiguousTopRank + } +} + +export const PANE_AGENT_TITLE_SOURCE_BIT = PANE_AGENT_IDENTITY_SOURCE_BITS.title +export const PANE_AGENT_IDENTITY_SOURCE_ORDER = PANE_AGENT_EVIDENCE_SOURCES diff --git a/src/shared/pane-agent-identity-observation-scheduler.ts b/src/shared/pane-agent-identity-observation-scheduler.ts new file mode 100644 index 00000000000..80badd575c9 --- /dev/null +++ b/src/shared/pane-agent-identity-observation-scheduler.ts @@ -0,0 +1,145 @@ +import type { PaneAgentIdentityHostKind } from './pane-agent-identity-evidence' + +export const RUN_SETTLE_MS = 5_000 +export const TITLE_CANDIDATE_WINDOW_MS = 30_000 +export const MAX_PENDING_IDENTITY_OBSERVATIONS = 4_096 +export const MAX_LIVE_FINALIZED_RUN_KEYS = 4_096 + +export type PaneAgentIdentityObservationSchedulerDeps = { + now?: () => number + onRunFreeze: (key: string) => void + onTitleCandidateFreeze: (hostKind: PaneAgentIdentityHostKind, key: string) => void + onFinalizedOverflow?: ( + kind: 'run' | 'title-candidate', + hostKind: PaneAgentIdentityHostKind | undefined, + key: string + ) => void +} + +/** Event-driven settle/deadline scheduler; it never probes or polls a host. */ +export class PaneAgentIdentityObservationScheduler { + private readonly pending = new Map>() + private readonly candidates = new Map>() + private readonly finalized = new Set() + private readonly now: () => number + constructor(private readonly deps: PaneAgentIdentityObservationSchedulerDeps) { + this.now = deps.now ?? Date.now + } + scheduleRun(key: string, settleMs = RUN_SETTLE_MS): boolean { + if ( + this.pending.size + this.candidates.size >= MAX_PENDING_IDENTITY_OBSERVATIONS && + !this.pending.has(key) + ) { + return false + } + if (this.finalized.has(key)) { + return false + } + this.cancelRun(key) + const timer = setTimeout( + () => { + if (!this.freezeRun(key)) { + this.deps.onFinalizedOverflow?.('run', undefined, key) + } + }, + Math.max(0, settleMs) + ) + timer.unref?.() + this.pending.set(key, timer) + return true + } + scheduleTitleCandidate( + key: string, + hostKind: PaneAgentIdentityHostKind, + windowMs = TITLE_CANDIDATE_WINDOW_MS + ): boolean { + if ( + this.pending.size + this.candidates.size >= MAX_PENDING_IDENTITY_OBSERVATIONS && + !this.candidates.has(key) + ) { + return false + } + if (this.candidates.has(key)) { + return true + } + const timer = setTimeout( + () => { + if (!this.freezeTitleCandidate(key, hostKind)) { + this.deps.onFinalizedOverflow?.('title-candidate', hostKind, key) + } + }, + Math.max(0, windowMs) + ) + timer.unref?.() + this.candidates.set(key, timer) + return true + } + attestRun(key: string): boolean { + return this.scheduleRun(key) + } + freezeRun(key: string): boolean { + if (this.finalized.has(key)) { + return false + } + if (this.finalized.size >= MAX_LIVE_FINALIZED_RUN_KEYS) { + this.cancelRun(key) + return false + } + this.cancelRun(key) + this.finalized.add(key) + this.deps.onRunFreeze(key) + return true + } + freezeTitleCandidate(key: string, hostKind: PaneAgentIdentityHostKind): boolean { + if (this.finalized.has(key)) { + return false + } + if (this.finalized.size >= MAX_LIVE_FINALIZED_RUN_KEYS) { + this.cancelCandidate(key) + return false + } + this.cancelCandidate(key) + this.finalized.add(key) + this.deps.onTitleCandidateFreeze(hostKind, key) + return true + } + cancelCandidate(key: string): void { + const timer = this.candidates.get(key) + if (timer) { + clearTimeout(timer) + } + this.candidates.delete(key) + } + cancelRun(key: string): void { + const timer = this.pending.get(key) + if (timer) { + clearTimeout(timer) + } + this.pending.delete(key) + } + exitOrRebind(key: string): void { + this.cancelRun(key) + this.cancelCandidate(key) + this.finalized.delete(key) + } + shutdown(): void { + for (const timer of this.pending.values()) { + clearTimeout(timer) + } + for (const timer of this.candidates.values()) { + clearTimeout(timer) + } + this.pending.clear() + this.candidates.clear() + this.finalized.clear() + } + isPendingRun(key: string): boolean { + return this.pending.has(key) + } + isPendingCandidate(key: string): boolean { + return this.candidates.has(key) + } + get clock(): number { + return this.now() + } +} diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index a1692e5f754..3f3404e1cf1 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -10,6 +10,7 @@ import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' import type { TuiAgent } from './tui-agent' +import type { PaneAgentIdentityAvailabilitySnapshot } from './pane-agent-identity-availability' export type RuntimeTerminalSummary = { handle: string @@ -93,6 +94,8 @@ export type RuntimeTerminalListResult = { truncated: boolean /** Absent from hosts that predate the field; treat that scope as unverifiable. */ hostScope?: RuntimeTerminalListHostScope + /** Optional cumulative, privacy-reduced pane identity census from the authority. */ + agentIdentityAvailability?: PaneAgentIdentityAvailabilitySnapshot } export type RuntimeTerminalOrphanAdoptionClaim = { diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 0466ca79430..9d3ad07c87f 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -1351,6 +1351,45 @@ const terminalPaneSplitSchema = z }) .strict() +const paneAgentIdentityHostKindSchema = z.enum(['native', 'wsl-host', 'wsl-distro', 'ssh', 'relay']) +const paneAgentIdentityLaunchModeSchema = z.enum(['typed', 'orca-launch', 'resume']) +const paneAgentIdentityAggregateRowSchema = z + .object({ + kind: z.literal('aggregate'), + host_kind: paneAgentIdentityHostKindSchema, + launch_mode: paneAgentIdentityLaunchModeSchema, + attested_runs: z.number().int().min(1).max(1_000_000_000), + no_evidence: z.number().int().min(0).max(1_000_000_000), + title_only: z.number().int().min(0).max(1_000_000_000), + identity_null: z.number().int().min(0).max(1_000_000_000), + ambiguous_top_rank: z.number().int().min(0).max(1_000_000_000) + }) + .strict() +const paneAgentIdentityCoverageRowSchema = z + .object({ + kind: z.literal('coverage'), + host_kind: paneAgentIdentityHostKindSchema, + reason: z.enum([ + 'snapshot', + 'baseline', + 'epoch_changed', + 'counter_regressed', + 'capacity', + 'candidate', + 'overflow', + 'truncated' + ]), + count: z.number().int().min(1).max(1_000_000_000) + }) + .strict() +const paneAgentIdentityAvailabilitySchema = z + .object({ + rows: z + .array(z.union([paneAgentIdentityAggregateRowSchema, paneAgentIdentityCoverageRowSchema])) + .max(64) + }) + .strict() + // Why: measures the changed-on-disk conflict flow (issue #7265) per transport; deliberately path-free. const editorExternalChangeConflictShownSchema = z .object({ @@ -1518,6 +1557,7 @@ export const eventSchemas = { editor_external_change_conflict_action: editorExternalChangeConflictActionSchema, direct_ssh_reconnect_operation: directSshReconnectOperationSchema, + pane_agent_identity_availability: paneAgentIdentityAvailabilitySchema, smart_sort_class_distribution: smartSortClassDistributionSchema, smart_sort_class_1_promotion: smartSortClass1PromotionSchema,