diff --git a/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts b/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts
new file mode 100644
index 00000000000..6c543db3594
--- /dev/null
+++ b/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts
@@ -0,0 +1,198 @@
+import { writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import {
+ resolveCanonicalPaneAgentIdentity,
+ type CanonicalPaneAgentIdentity
+} from '../../../shared/pane-agent-identity-adapter'
+import { resolveTabAgentFromSignals } from './tab-agent-from-signals'
+import type { TuiAgent } from '../../../shared/tui-agent'
+
+const AGENTS: readonly TuiAgent[] = ['claude', 'codex']
+const SLOT_COUNT = 7
+const SHAPE_COUNT = 3 ** SLOT_COUNT * 4 * 2
+const TITLES: readonly string[] = ['', 'zsh', 'Task - claude', 'Task - codex']
+
+type Breakdown = Record<
+ 'launch' | 'completed-hook' | 'sleeping-session' | 'process' | 'sibling' | 'title',
+ number
+>
+
+function slotValues(mask: number): (TuiAgent | null)[] {
+ let remaining = mask
+ return Array.from({ length: SLOT_COUNT }, () => {
+ const value = remaining % 3
+ remaining = Math.floor(remaining / 3)
+ return value === 0 ? null : AGENTS[value - 1]
+ })
+}
+
+function canonicalResult(
+ values: readonly (TuiAgent | null)[],
+ title: string,
+ withProof: boolean
+): CanonicalPaneAgentIdentity {
+ const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] = values
+ return resolveCanonicalPaneAgentIdentity({
+ hookAgent: hook,
+ hookIsLive: hook !== null,
+ completedHookAgent: completed,
+ launchAgent: launch,
+ foregroundAgent: process,
+ processProof:
+ withProof && process
+ ? {
+ agent: process,
+ processIncarnation: 'fixture-process',
+ authorityId: 'fixture-authority',
+ capturedAgeMs: 10,
+ validForMs: 1_000
+ }
+ : undefined,
+ sleepingSessionAgent: sleeping,
+ siblingAgents: [siblingHook, siblingCompleted].filter(
+ (agent): agent is TuiAgent => agent !== null
+ ),
+ allowSibling: true,
+ title
+ })
+}
+
+function realResult(values: readonly (TuiAgent | null)[], title: string, remote: boolean) {
+ const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] = values
+ // The seven slots model steady-state observations; this runtime memory bit is intentionally
+ // held true instead of adding an eighth dimension to the approved 17,496-shape table.
+ return resolveTabAgentFromSignals({
+ hasObservedAgentSignal: true,
+ isRemote: remote,
+ title,
+ hookAgent: hook,
+ siblingHookAgent: siblingHook,
+ focusedCompletedHookAgent: completed,
+ siblingCompletedHookAgent: siblingCompleted,
+ processAgent: process,
+ processShellForeground: false,
+ sleepingSessionAgent: sleeping,
+ launchAgent: launch ?? undefined
+ })
+}
+
+function runDecisionTable(withProof: boolean) {
+ let disagreements = 0
+ let flipped = 0
+ const breakdown: Breakdown = {
+ launch: 0,
+ 'completed-hook': 0,
+ 'sleeping-session': 0,
+ process: 0,
+ sibling: 0,
+ title: 0
+ }
+ for (let mask = 0; mask < 3 ** SLOT_COUNT; mask += 1) {
+ const values = slotValues(mask)
+ for (const title of TITLES) {
+ for (const remote of [false, true]) {
+ const real = realResult(values, title, remote)
+ const canonical = canonicalResult(values, title, withProof)
+ if (real !== canonical.agent) {
+ disagreements += 1
+ if (canonical.source !== null) {
+ breakdown[canonical.source] += 1
+ }
+ }
+ if (!withProof) {
+ const proven = canonicalResult(values, title, true)
+ if (
+ canonical.agent !== proven.agent &&
+ proven.source === 'process' &&
+ (canonical.source === 'launch' ||
+ canonical.source === 'completed-hook' ||
+ canonical.source === 'sleeping-session')
+ ) {
+ flipped += 1
+ }
+ }
+ }
+ }
+ }
+ return { disagreements, flipped, breakdown }
+}
+
+describe('renderer ladder decision table', () => {
+ it('replays the real shipping ladder and records all rung disagreements', () => {
+ const proofFree = runDecisionTable(false)
+ const freshProof = runDecisionTable(true)
+ const result = {
+ shapes: SHAPE_COUNT,
+ proofOmitted: proofFree,
+ freshProof,
+ flippedByAddingProof: proofFree.flipped
+ }
+ writeFileSync(
+ join(tmpdir(), 'orca-pane-agent-identity-decision-table-real.json'),
+ `${JSON.stringify(result, null, 2)}\n`
+ )
+ // Re-derived against resolveTabAgentFromSignals (not a hand-written model). These differ from
+ // the approved 2,520/648 totals and 396/144/72/36 breakdown; see the PR comment.
+ expect(proofFree).toEqual({
+ disagreements: 2_622,
+ flipped: 1_872,
+ breakdown: {
+ launch: 1_884,
+ 'completed-hook': 478,
+ 'sleeping-session': 144,
+ process: 0,
+ sibling: 54,
+ title: 6
+ }
+ })
+ expect(freshProof).toEqual({
+ disagreements: 658,
+ flipped: 0,
+ breakdown: {
+ launch: 588,
+ 'completed-hook': 46,
+ 'sleeping-session': 0,
+ process: 0,
+ sibling: 6,
+ title: 2
+ }
+ })
+ expect(proofFree.flipped).toBe(1_872)
+ })
+
+ it('requires both freshness fields before process evidence can change the no-proof result', () => {
+ const values = [null, null, null, null, 'codex', null, 'claude'] as const
+ expect(canonicalResult(values, '', false)).toMatchObject({
+ agent: 'claude',
+ source: 'launch'
+ })
+ expect(
+ resolveCanonicalPaneAgentIdentity({
+ foregroundAgent: 'codex',
+ processProof: {
+ agent: 'codex',
+ processIncarnation: 'fixture-process',
+ authorityId: 'fixture-authority',
+ capturedAgeMs: undefined as unknown as number,
+ validForMs: 1_000
+ },
+ launchAgent: 'claude'
+ })
+ ).toMatchObject({ agent: 'claude', source: 'launch' })
+ expect(
+ resolveCanonicalPaneAgentIdentity({
+ foregroundAgent: 'codex',
+ processProof: {
+ agent: 'codex',
+ processIncarnation: 'fixture-process',
+ authorityId: 'fixture-authority',
+ capturedAgeMs: 10,
+ validForMs: undefined as unknown as number
+ },
+ launchAgent: 'claude'
+ })
+ ).toMatchObject({ agent: 'claude', source: 'launch' })
+ })
+})
diff --git a/src/shared/agent-status-identity.ts b/src/shared/agent-status-identity.ts
index f3d4b65a86b..9cb81625310 100644
--- a/src/shared/agent-status-identity.ts
+++ b/src/shared/agent-status-identity.ts
@@ -4,6 +4,8 @@ import {
type AgentStatusState,
type AgentType
} from './agent-status-types'
+import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter'
+import type { TuiAgent } from './tui-agent'
type ExistingAgentIdentity = {
agentType?: AgentType
@@ -63,6 +65,11 @@ export function resolveAgentStatusIdentity(args: {
inheritedFromActivePane: false
}
}
+ const canonical = resolveCanonicalPaneAgentIdentity({
+ hookAgent: incomingAgentType as TuiAgent,
+ hookIsLive: true,
+ completedHookAgent: args.existing.state === 'done' ? (existingAgentType as TuiAgent) : undefined
+ })
if (isActiveExistingIdentity(args.existing, args.now, staleAfterMs)) {
return {
// Why: child agent CLIs inherit ORCA_PANE_KEY from their parent terminal.
@@ -74,7 +81,7 @@ export function resolveAgentStatusIdentity(args: {
}
return {
- agentType: incomingAgentType,
+ agentType: canonical.agent ?? incomingAgentType,
inheritedFromActivePane: false
}
}
diff --git a/src/shared/pane-agent-evidence-sources.ts b/src/shared/pane-agent-evidence-sources.ts
new file mode 100644
index 00000000000..32b3648494a
--- /dev/null
+++ b/src/shared/pane-agent-evidence-sources.ts
@@ -0,0 +1,19 @@
+/** Evidence classes in canonical strength order, strongest first. */
+export const PANE_AGENT_EVIDENCE_SOURCES = [
+ /** A live provider hook for a turn in progress. The agent is running and said so. */
+ 'live-hook',
+ /** The pane's foreground process, as read on the execution host. */
+ 'process',
+ /** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. */
+ 'launch',
+ /** A provider hook from a turn that finished. Still authoritative about identity. */
+ 'completed-hook',
+ /** A sleeping session record restored for this pane. */
+ 'sleeping-session',
+ /** Another pane in the same tab. Tab-level surfaces only; never pane-scoped routing. */
+ 'sibling',
+ /** Parsed from the terminal title. A decoration channel; anyone can type an agent's name. */
+ 'title'
+] as const
+
+export type PaneAgentEvidenceSource = (typeof PANE_AGENT_EVIDENCE_SOURCES)[number]
diff --git a/src/shared/pane-agent-identity-adapter.test.ts b/src/shared/pane-agent-identity-adapter.test.ts
new file mode 100644
index 00000000000..d898cb9adcf
--- /dev/null
+++ b/src/shared/pane-agent-identity-adapter.test.ts
@@ -0,0 +1,278 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildPaneAgentIdentityEvidenceWire,
+ isForegroundProcessProofFresh,
+ resolveCanonicalPaneAgentIdentity,
+ type ForegroundProcessProof
+} from './pane-agent-identity-adapter'
+
+const freshProof: ForegroundProcessProof = {
+ agent: 'codex',
+ processIncarnation: 'opaque-pid-token',
+ authorityId: 'main:test',
+ capturedAgeMs: 50,
+ validForMs: 5_000
+}
+
+describe('per-pane coverage gate', () => {
+ it('covers a pane from hook, launch, or sleeping-session evidence alone', () => {
+ expect(
+ resolveCanonicalPaneAgentIdentity({ hookAgent: 'claude', hookIsLive: true }).coverage
+ ).toBe('covered')
+ expect(resolveCanonicalPaneAgentIdentity({ completedHookAgent: 'claude' }).coverage).toBe(
+ 'covered'
+ )
+ expect(resolveCanonicalPaneAgentIdentity({ launchAgent: 'codex' }).coverage).toBe('covered')
+ expect(resolveCanonicalPaneAgentIdentity({ sleepingSessionAgent: 'gemini' }).coverage).toBe(
+ 'covered'
+ )
+ })
+
+ it('never covers a pane from a title, a sibling, or a bare foreground name', () => {
+ expect(resolveCanonicalPaneAgentIdentity({ title: 'claude' }).coverage).toBe('uncovered')
+ expect(
+ resolveCanonicalPaneAgentIdentity({ siblingAgent: 'claude', allowSibling: true }).coverage
+ ).toBe('uncovered')
+ expect(resolveCanonicalPaneAgentIdentity({ foregroundAgent: 'codex' }).coverage).toBe(
+ 'uncovered'
+ )
+ })
+
+ it('is computed from evidence, never from a platform or remote flag', () => {
+ // The input deliberately has no platform/isRemote field to branch on; this pins that a
+ // hook-covered pane resolves identically regardless of any caller-side host knowledge.
+ const identity = resolveCanonicalPaneAgentIdentity({ hookAgent: 'claude', hookIsLive: true })
+ expect(identity).toMatchObject({ agent: 'claude', source: 'live-hook', coverage: 'covered' })
+ })
+})
+
+describe('process rung requires a host-stamped proof', () => {
+ it('rejects a stale or malformed proof and accepts a fresh one', () => {
+ expect(isForegroundProcessProofFresh(freshProof)).toBe(true)
+ expect(isForegroundProcessProofFresh({ ...freshProof, capturedAgeMs: 6_000 })).toBe(false)
+ expect(isForegroundProcessProofFresh({ ...freshProof, capturedAgeMs: -1 })).toBe(false)
+ expect(isForegroundProcessProofFresh({ ...freshProof, validForMs: 0 })).toBe(false)
+ expect(isForegroundProcessProofFresh({ ...freshProof, capturedAgeMs: Number.NaN })).toBe(false)
+ })
+
+ it('a bare foreground name cannot outrank launch; a proven process can', () => {
+ const unproven = resolveCanonicalPaneAgentIdentity({
+ launchAgent: 'claude',
+ foregroundAgent: 'codex'
+ })
+ expect(unproven).toMatchObject({ agent: 'claude', source: 'launch' })
+
+ const proven = resolveCanonicalPaneAgentIdentity({
+ launchAgent: 'claude',
+ foregroundAgent: 'codex',
+ processProof: freshProof
+ })
+ expect(proven).toMatchObject({ agent: 'codex', source: 'process', coverage: 'covered' })
+ })
+
+ it('an expired proof and a name-mismatched proof both drop the process rung', () => {
+ const expired = resolveCanonicalPaneAgentIdentity({
+ launchAgent: 'claude',
+ foregroundAgent: 'codex',
+ processProof: { ...freshProof, capturedAgeMs: 10_000 }
+ })
+ expect(expired).toMatchObject({ agent: 'claude', source: 'launch' })
+
+ const mismatched = resolveCanonicalPaneAgentIdentity({
+ launchAgent: 'claude',
+ foregroundAgent: 'gemini',
+ processProof: freshProof
+ })
+ expect(mismatched).toMatchObject({ agent: 'claude', source: 'launch' })
+ })
+})
+
+describe('uncovered compatibility lane', () => {
+ it('preserves the caller-provided legacy result verbatim', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ title: 'Fix the parser - grok',
+ uncoveredFallback: { agent: 'grok', titleOnly: true }
+ })
+ expect(identity).toMatchObject({
+ agent: 'grok',
+ source: 'title',
+ coverage: 'uncovered',
+ titleOnly: true
+ })
+ })
+
+ it('answers from title evidence marked title-only when no fallback is supplied', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok'
+ })
+ expect(identity).toMatchObject({
+ agent: 'grok',
+ source: 'title',
+ coverage: 'uncovered',
+ titleOnly: true
+ })
+ })
+
+ it('a legacy null stays null rather than re-deriving from the title', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ title: 'anything - grok',
+ uncoveredFallback: { agent: null }
+ })
+ expect(identity).toMatchObject({ agent: null, source: null, coverage: 'uncovered' })
+ })
+
+ it('does not let a legacy title fallback bypass the ambiguity fence', () => {
+ expect(
+ resolveCanonicalPaneAgentIdentity({
+ title: 'OC | something - grok',
+ uncoveredFallback: { agent: 'opencode', titleOnly: true }
+ })
+ ).toMatchObject({ agent: null, source: null, ambiguousAt: 'title' })
+ expect(
+ resolveCanonicalPaneAgentIdentity({
+ title: 'compare codex with grok',
+ uncoveredFallback: { agent: 'codex', titleOnly: true }
+ })
+ ).toMatchObject({ agent: null, source: null, coverage: 'uncovered' })
+ })
+
+ it('does not label a foreground-only compatibility answer as title-only', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ foregroundAgent: 'codex',
+ uncoveredFallback: { agent: 'codex' }
+ })
+ expect(identity).toMatchObject({
+ agent: 'codex',
+ source: null,
+ coverage: 'uncovered',
+ titleOnly: false
+ })
+ })
+})
+
+describe('canonical ladder inside the covered lane', () => {
+ it('keeps title last: a covered launch beats a parsed title', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ launchAgent: 'claude',
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok'
+ })
+ expect(identity).toMatchObject({ agent: 'claude', source: 'launch', titleOnly: false })
+ })
+
+ it('sibling evidence needs the explicit tab-scope opt-in', () => {
+ const withoutOptIn = resolveCanonicalPaneAgentIdentity({
+ launchAgent: 'claude',
+ siblingAgent: 'codex'
+ })
+ expect(withoutOptIn.agent).toBe('claude')
+ const optedIn = resolveCanonicalPaneAgentIdentity({
+ hookAgent: 'claude',
+ hookIsLive: true,
+ siblingAgent: 'codex',
+ allowSibling: true
+ })
+ expect(optedIn).toMatchObject({ agent: 'claude', source: 'live-hook' })
+ })
+
+ it('surfaces ambiguity instead of picking by array order', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ hookAgent: 'claude',
+ hookIsLive: false,
+ completedHookAgent: 'codex'
+ })
+ expect(identity).toMatchObject({ agent: null, ambiguousAt: 'completed-hook' })
+ })
+})
+
+describe('reclaim-versus-stale-hook discriminator (run keys, not title text)', () => {
+ const run1 = { authorityId: 'main:a', incarnation: 1 }
+ const run2 = { authorityId: 'main:a', incarnation: 2 }
+ const otherAuthority = { authorityId: 'renderer:b', incarnation: 9 }
+
+ it('bug shape: hook and pane share the current run, so the completed hook wins over the title', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ completedHookAgent: 'claude',
+ completedHookRun: run1,
+ currentRun: run1,
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok'
+ })
+ expect(identity).toMatchObject({ agent: 'claude', source: 'completed-hook' })
+ })
+
+ it('reclaim shape: a superseded hook is ineligible and the current title evidence answers', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ completedHookAgent: 'claude',
+ completedHookRun: run1,
+ currentRun: run2,
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok'
+ })
+ expect(identity).toMatchObject({
+ agent: 'grok',
+ source: 'title',
+ coverage: 'uncovered',
+ titleOnly: true
+ })
+ expect(identity.supersededSources).toEqual(['completed-hook'])
+ })
+
+ it('cross-authority runs are incomparable, so the hook stays eligible', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ completedHookAgent: 'claude',
+ completedHookRun: otherAuthority,
+ currentRun: run2,
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok'
+ })
+ expect(identity).toMatchObject({ agent: 'claude', source: 'completed-hook' })
+ })
+
+ it('an absent run key keeps evidence eligible (old peer), never guessed stale', () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ completedHookAgent: 'claude',
+ currentRun: run2,
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok'
+ })
+ expect(identity).toMatchObject({ agent: 'claude', source: 'completed-hook' })
+ })
+})
+
+describe('action floor', () => {
+ it("minimumSource: 'launch' refuses title and completed-hook answers outright", () => {
+ const identity = resolveCanonicalPaneAgentIdentity({
+ completedHookAgent: 'claude',
+ title: 'claude',
+ minimumSource: 'launch'
+ })
+ expect(identity).toMatchObject({ agent: null, source: null, coverage: 'covered' })
+ })
+})
+
+describe('wire evidence projection', () => {
+ it('publishes nothing for an absent identity — absence stays absence', () => {
+ expect(
+ buildPaneAgentIdentityEvidenceWire(resolveCanonicalPaneAgentIdentity({}))
+ ).toBeUndefined()
+ })
+
+ it('marks the uncovered title-only route explicitly and carries the run key when known', () => {
+ const wire = buildPaneAgentIdentityEvidenceWire(
+ resolveCanonicalPaneAgentIdentity({ title: 'claude - claude' }),
+ { authorityId: 'main:a', incarnation: 3 },
+ { capturedAgeMs: 10, validForMs: 1_000 }
+ )
+ expect(wire).toMatchObject({
+ coverage: 'uncovered',
+ titleOnlyActionFallback: true,
+ authorityId: 'main:a',
+ incarnation: 3,
+ freshness: { capturedAgeMs: 10, validForMs: 1_000 }
+ })
+ })
+
+ it('a covered identity never carries the title-only action marker', () => {
+ const wire = buildPaneAgentIdentityEvidenceWire(
+ resolveCanonicalPaneAgentIdentity({ launchAgent: 'claude' })
+ )
+ expect(wire).toMatchObject({ source: 'launch', coverage: 'covered' })
+ expect(wire?.titleOnlyActionFallback).toBeUndefined()
+ })
+})
diff --git a/src/shared/pane-agent-identity-adapter.ts b/src/shared/pane-agent-identity-adapter.ts
new file mode 100644
index 00000000000..c08e1b77dea
--- /dev/null
+++ b/src/shared/pane-agent-identity-adapter.ts
@@ -0,0 +1,361 @@
+import { collectAgentTitleEvidence } from './agent-title-evidence'
+import { PANE_AGENT_EVIDENCE_SOURCES } from './pane-agent-evidence-sources'
+import type {
+ PaneAgentEvidence,
+ PaneAgentIdentity,
+ PaneAgentIdentityInput,
+ PaneAgentRunKey
+} from './pane-agent-identity-resolver'
+import type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources'
+import type { TuiAgent } from './tui-agent'
+
+/**
+ * Canonical pane identity ranking. All adapters, including the compatibility resolver, delegate to
+ * this implementation so source precedence, ambiguity, and run eligibility cannot drift.
+ */
+
+/**
+ * Whether the execution authority proved at least one identity-bearing source for this pane.
+ * Computed from evidence presence, never from `platform`, `isRemote`, or OS: a remote pane with a
+ * host-stamped hook is covered; a local pane with only a title is uncovered.
+ */
+export type PaneAgentCoverage = 'covered' | 'uncovered'
+
+/**
+ * Host-stamped proof that a recognized agent process is the pane's foreground process.
+ *
+ * A process NAME is not a PID-reuse-safe identity, so a bare foreground read never enters the
+ * covered process rung. `processIncarnation` is an opaque token the execution host derives from
+ * the selected PID plus start/creation time (or an equivalent platform-native identity); the raw
+ * tuple never crosses the renderer/remote wire. The host emits no proof when the start identity
+ * is unavailable or ambiguous, so that pane reads `uncovered` rather than guessed.
+ */
+export type ForegroundProcessProof = {
+ agent: TuiAgent
+ /** Opaque host-derived PID+start-time token. Compared for equality only, never decoded. */
+ processIncarnation: string
+ ptyIncarnationId?: string
+ /** The execution authority that stamped the proof (see agent-status-observation.ts). */
+ authorityId: string
+ /** Age on the AUTHORITY's clock at capture. Replicas decay from this plus `validForMs`,
+ * never by subtracting a host wall clock from a local `Date.now()`. */
+ capturedAgeMs: number
+ validForMs: number
+}
+
+/**
+ * Positive evidence that a pane/process was REPLACED, required before any consumer may advance a
+ * pane incarnation. A retired-pane `restart` disposition, an accepted send, an ordinary provider
+ * turn boundary, a title change, a transport loss, or a renderer-only foreground change is never
+ * one of these. Defined here so the rebind-gate wave has a contract to be correct against; no
+ * sequencer call site consumes it yet.
+ */
+export type PaneReplacementProof =
+ | { kind: 'accepted-launch'; launchToken: string; ptyIncarnationId: string }
+ | { kind: 'process-replacement'; processIncarnation: string; authorityId: string }
+ | { kind: 'provider-session-attach'; providerSessionId: string }
+
+/**
+ * The optional wire object a host will publish alongside `agentIdentity` after capability
+ * negotiation (host-publisher wave, not now). All fields bounded and JSON-safe; old peers ignore
+ * it. Never inferred from the bare `agentIdentity` string.
+ */
+export type PaneAgentIdentityEvidenceWire = {
+ source: PaneAgentEvidenceSource
+ coverage: PaneAgentCoverage
+ authorityId?: string
+ incarnation?: number
+ freshness?: { capturedAgeMs: number; validForMs: number }
+ /** Marks the scoped host-published title-only best-effort route (hand-started WSL panes).
+ * Counted separately, `unverifiable` for liveness, and never relabeled as covered proof. */
+ titleOnlyActionFallback?: true
+}
+
+export type CanonicalPaneAgentIdentityInput = {
+ hookAgent?: TuiAgent | null
+ hookIsLive?: boolean
+ hookRun?: PaneAgentRunKey
+ /** A distinct completed-hook signal for callers that hold live and completed rows separately
+ * (the tab ladder does); `hookAgent` + `hookIsLive: false` remains the single-slot spelling. */
+ completedHookAgent?: TuiAgent | null
+ completedHookRun?: PaneAgentRunKey
+ launchAgent?: TuiAgent | null
+ launchRun?: PaneAgentRunKey
+ /**
+ * Foreground process NAME as currently read. Without a fresh `processProof` this is a weak
+ * hint: it neither enters the covered process rung nor makes the pane covered.
+ */
+ foregroundAgent?: TuiAgent | null
+ processProof?: ForegroundProcessProof | null
+ sleepingSessionAgent?: TuiAgent | null
+ sleepingRun?: PaneAgentRunKey
+ /** Tab-level display fallback only; ignored unless `allowSibling` opts in. */
+ siblingAgent?: TuiAgent | null
+ /** Additional tab-level sibling observations retained for ambiguity checking. */
+ siblingAgents?: readonly TuiAgent[]
+ allowSibling?: boolean
+ title?: string | null
+ currentRun?: PaneAgentRunKey
+ minimumSource?: PaneAgentEvidenceSource
+ /**
+ * The caller's CURRENT ladder result, preserved verbatim while the pane is uncovered. The
+ * uncovered lane is a temporary compatibility lane, not a new host-specific ranking; absent a
+ * fallback, an uncovered pane answers from title evidence alone, marked title-only.
+ */
+ uncoveredFallback?: { agent: TuiAgent | null; titleOnly?: boolean }
+}
+
+export type CanonicalPaneAgentIdentity = {
+ agent: TuiAgent | null
+ source: PaneAgentEvidenceSource | null
+ coverage: PaneAgentCoverage
+ /** True when the answer was derived from a parsed title (the uncovered/title-only marking). */
+ titleOnly: boolean
+ ambiguousAt?: PaneAgentEvidenceSource
+ supersededSources: readonly PaneAgentEvidenceSource[]
+}
+
+/** Authority order, strongest first. This is the only place precedence is expressed. */
+const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES
+
+/** Exported for the source/rank drift ratchet; the rank is the canonical source list itself. */
+export const PANE_AGENT_SOURCE_RANK = SOURCE_RANK
+
+/** Reject an unrecognised source instead of silently dropping it from the ranking loop. */
+function sourceRankIndex(source: PaneAgentEvidenceSource): number {
+ const index = SOURCE_RANK.indexOf(source)
+ if (index === -1) {
+ throw new Error(`Unknown pane-agent evidence source: ${String(source)}`)
+ }
+ return index
+}
+
+/** Run keys only supersede evidence from the same authority; unknown authorities stay eligible. */
+function isPaneAgentRunEligible(
+ run: PaneAgentRunKey | undefined,
+ currentRun: PaneAgentRunKey | undefined
+): boolean {
+ return (
+ run === undefined ||
+ currentRun === undefined ||
+ run.authorityId !== currentRun.authorityId ||
+ run.incarnation === currentRun.incarnation
+ )
+}
+
+/** Shared evidence ranking primitive used by every pane-identity adapter. */
+export function resolveCanonicalPaneAgentEvidence(
+ input: PaneAgentIdentityInput
+): PaneAgentIdentity {
+ const superseded: PaneAgentEvidenceSource[] = []
+ const floor = input.minimumSource ? sourceRankIndex(input.minimumSource) : Number.MAX_SAFE_INTEGER
+ const eligible = input.evidence.filter((item) => {
+ if (item.source === 'sibling' && input.allowSibling !== true) {
+ return false
+ }
+ if (sourceRankIndex(item.source) > floor) {
+ return false
+ }
+ if (isPaneAgentRunEligible(item.run, input.currentRun)) {
+ return true
+ }
+ superseded.push(item.source)
+ return false
+ })
+
+ for (const source of SOURCE_RANK) {
+ const matches = eligible.filter((item) => item.source === source)
+ if (matches.length === 0) {
+ continue
+ }
+ const agents = new Set(matches.map((item) => item.agent))
+ if (agents.size > 1) {
+ return { agent: null, source: null, ambiguousAt: source, supersededSources: superseded }
+ }
+ return { agent: matches[0].agent, source, supersededSources: superseded }
+ }
+ return { agent: null, source: null, supersededSources: superseded }
+}
+
+/** Freshness is judged on the authority's own clock: age at capture against its TTL. */
+export function isForegroundProcessProofFresh(proof: ForegroundProcessProof): boolean {
+ return (
+ Number.isFinite(proof.capturedAgeMs) &&
+ Number.isFinite(proof.validForMs) &&
+ proof.capturedAgeMs >= 0 &&
+ proof.validForMs > 0 &&
+ proof.capturedAgeMs <= proof.validForMs
+ )
+}
+
+/** A proof only carries identity for the agent it names; a name mismatch is no proof at all. */
+function processEvidenceFromProof(
+ input: CanonicalPaneAgentIdentityInput
+): PaneAgentEvidence | null {
+ const proof = input.processProof
+ if (!proof || !isForegroundProcessProofFresh(proof)) {
+ return null
+ }
+ if (input.foregroundAgent && input.foregroundAgent !== proof.agent) {
+ return null
+ }
+ return { source: 'process', agent: proof.agent }
+}
+
+export function resolveCanonicalPaneAgentIdentity(
+ input: CanonicalPaneAgentIdentityInput
+): CanonicalPaneAgentIdentity {
+ const processEvidence = processEvidenceFromProof(input)
+ // Coverage comes from authority-bearing sources that are still eligible for this run. A stale
+ // hook/launch row can remain in the input after a pane is replaced; it must not make a title-only
+ // answer look covered to a future action consumer.
+ const covered = Boolean(
+ (input.hookAgent && isPaneAgentRunEligible(input.hookRun, input.currentRun)) ||
+ (input.completedHookAgent &&
+ isPaneAgentRunEligible(input.completedHookRun, input.currentRun)) ||
+ processEvidence ||
+ (input.launchAgent && isPaneAgentRunEligible(input.launchRun, input.currentRun)) ||
+ (input.sleepingSessionAgent && isPaneAgentRunEligible(input.sleepingRun, input.currentRun))
+ )
+ // Keep stale evidence in the resolver so diagnostics still report which source was superseded,
+ // even when it no longer qualifies the pane as covered.
+ const hasAuthorityEvidence = Boolean(
+ input.hookAgent ||
+ input.completedHookAgent ||
+ processEvidence ||
+ input.launchAgent ||
+ input.sleepingSessionAgent
+ )
+ const titleEvidence = input.title ? collectAgentTitleEvidence(input.title) : null
+ const titleAgent = titleEvidence?.agent ?? null
+
+ if (!hasAuthorityEvidence) {
+ if (input.uncoveredFallback) {
+ const agent = input.uncoveredFallback.agent
+ // A legacy title parser may have picked the first token from an ambiguous or
+ // free-text-only title. Do not let that compatibility value bypass the canonical
+ // ambiguity fence when the caller marks it as title-only evidence.
+ const rejectTitleFallback =
+ input.uncoveredFallback.titleOnly === true &&
+ ((titleEvidence?.reason === 'free-text-only' &&
+ (titleEvidence.freeTextNames?.length ?? 0) > 1) ||
+ titleEvidence?.reason === 'conflicting-anchored-names' ||
+ titleEvidence?.reason === 'conflicting-vendor-markers')
+ if (rejectTitleFallback) {
+ return {
+ agent: null,
+ source: null,
+ coverage: 'uncovered',
+ titleOnly: false,
+ ...(titleEvidence?.reason === 'free-text-only' ? {} : { ambiguousAt: 'title' as const }),
+ supersededSources: []
+ }
+ }
+ const titleOnly =
+ input.uncoveredFallback.titleOnly ?? (agent !== null && agent === titleAgent)
+ return {
+ agent,
+ source: agent === null ? null : titleOnly ? 'title' : null,
+ coverage: 'uncovered',
+ titleOnly,
+ supersededSources: []
+ }
+ }
+ const siblingEvidence = [
+ ...(input.siblingAgent ? [{ source: 'sibling' as const, agent: input.siblingAgent }] : []),
+ ...(input.siblingAgents?.map((agent) => ({ source: 'sibling' as const, agent })) ?? []),
+ ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : [])
+ ]
+ const siblingResolved = resolveCanonicalPaneAgentEvidence({
+ evidence: siblingEvidence,
+ allowSibling: input.allowSibling,
+ minimumSource: input.minimumSource
+ })
+ return {
+ agent: siblingResolved.agent,
+ source: siblingResolved.source,
+ coverage: 'uncovered',
+ titleOnly: siblingResolved.source === 'title',
+ ...(siblingResolved.ambiguousAt ? { ambiguousAt: siblingResolved.ambiguousAt } : {}),
+ supersededSources: siblingResolved.supersededSources
+ }
+ }
+
+ const resolved = resolveCanonicalPaneAgentEvidence({
+ evidence: [
+ ...(input.hookAgent
+ ? [
+ {
+ source: input.hookIsLive ? ('live-hook' as const) : ('completed-hook' as const),
+ agent: input.hookAgent,
+ ...(input.hookRun ? { run: input.hookRun } : {})
+ }
+ ]
+ : []),
+ ...(input.completedHookAgent
+ ? [
+ {
+ source: 'completed-hook' as const,
+ agent: input.completedHookAgent,
+ ...(input.completedHookRun ? { run: input.completedHookRun } : {})
+ }
+ ]
+ : []),
+ ...(processEvidence ? [processEvidence] : []),
+ ...(input.launchAgent
+ ? [
+ {
+ source: 'launch' as const,
+ agent: input.launchAgent,
+ ...(input.launchRun ? { run: input.launchRun } : {})
+ }
+ ]
+ : []),
+ ...(input.sleepingSessionAgent
+ ? [
+ {
+ source: 'sleeping-session' as const,
+ agent: input.sleepingSessionAgent,
+ ...(input.sleepingRun ? { run: input.sleepingRun } : {})
+ }
+ ]
+ : []),
+ ...(input.siblingAgent ? [{ source: 'sibling' as const, agent: input.siblingAgent }] : []),
+ ...(input.siblingAgents?.map((agent) => ({ source: 'sibling' as const, agent })) ?? []),
+ ...(titleAgent ? [{ source: 'title' as const, agent: titleAgent }] : [])
+ ],
+ currentRun: input.currentRun,
+ minimumSource: input.minimumSource,
+ allowSibling: input.allowSibling
+ })
+ return {
+ agent: resolved.agent,
+ source: resolved.source,
+ coverage: covered ? 'covered' : 'uncovered',
+ titleOnly: resolved.source === 'title',
+ ...(resolved.ambiguousAt ? { ambiguousAt: resolved.ambiguousAt } : {}),
+ supersededSources: resolved.supersededSources
+ }
+}
+
+/** Projects the host-local sidecar onto the optional wire shape. Returns undefined when there is
+ * nothing to publish — absence stays absence, and a bare `agentIdentity` with no sidecar is
+ * never treated as covered proof by any consumer. */
+export function buildPaneAgentIdentityEvidenceWire(
+ identity: CanonicalPaneAgentIdentity,
+ run?: PaneAgentRunKey,
+ freshness?: { capturedAgeMs: number; validForMs: number }
+): PaneAgentIdentityEvidenceWire | undefined {
+ if (identity.agent === null || identity.source === null) {
+ return undefined
+ }
+ return {
+ source: identity.source,
+ coverage: identity.coverage,
+ ...(run ? { authorityId: run.authorityId, incarnation: run.incarnation } : {}),
+ ...(freshness ? { freshness } : {}),
+ ...(identity.coverage === 'uncovered' && identity.titleOnly
+ ? { titleOnlyActionFallback: true as const }
+ : {})
+ }
+}
diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts
index 6871a38eb6d..ee868bfcc16 100644
--- a/src/shared/pane-agent-identity-inventory.test.ts
+++ b/src/shared/pane-agent-identity-inventory.test.ts
@@ -18,7 +18,13 @@ const HELPERS = [
'resolveExplicitTerminalTitleAgentType',
'resolveCommittedTitleAgentType',
'resolvePaneAgentOwner',
- 'resolveCompatibleAgentTypeForOwner'
+ 'resolveCompatibleAgentTypeForOwner',
+ 'classifyTitleActivity',
+ 'detectAgentStatusFromTitle',
+ 'resolveAgentTypeFromTerminalTitle',
+ 'resolvePaneAgentIdentity',
+ 'resolveCanonicalPaneAgentIdentity',
+ 'resolvePublishedPaneAgentIdentity'
] as const
const TEST_SUPPORT_PATHS = new Set([
@@ -242,6 +248,136 @@ const INVENTORY: readonly InventoryGroup[] = [
['src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-retry-status.ts', 2],
['src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts', 2]
]
+ },
+ {
+ helper: 'classifyTitleActivity',
+ classification: 'identity-consumer',
+ paths: [
+ ['src/renderer/src/components/sidebar/smart-attention.ts', 3],
+ ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2],
+ ['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3],
+ ['src/renderer/src/lib/active-agent-note-target.ts', 2],
+ ['src/renderer/src/lib/worktree-status.ts', 3],
+ ['src/renderer/src/store/slices/terminal-helpers.ts', 2]
+ ]
+ },
+ {
+ helper: 'classifyTitleActivity',
+ classification: 'action-consumer',
+ paths: [
+ ['src/renderer/src/components/terminal-pane/cache-timer-seeding.ts', 2],
+ ['src/renderer/src/lib/agent-ready-wait.ts', 2],
+ ['src/renderer/src/store/terminals/terminal-ephemeral-state.ts', 2]
+ ]
+ },
+ {
+ helper: 'classifyTitleActivity',
+ classification: 'activity-only',
+ paths: [
+ ['src/renderer/src/store/slices/workspace-cleanup-local-evidence.ts', 3],
+ ['src/renderer/src/store/terminals/terminal-tab-presentation.ts', 4]
+ ]
+ },
+ {
+ helper: 'classifyTitleActivity',
+ classification: 'evidence-producer',
+ paths: [
+ ['src/renderer/src/lib/agent-send-title-status.ts', 2],
+ ['src/renderer/src/lib/agent-status-terminal-title.ts', 2]
+ ]
+ },
+ {
+ helper: 'classifyTitleActivity',
+ classification: 'parser-implementation',
+ paths: [
+ ['src/renderer/src/lib/agent-status.ts', 5],
+ 'src/renderer/src/lib/pane-agent-evidence.ts'
+ ]
+ },
+ {
+ helper: 'detectAgentStatusFromTitle',
+ classification: 'evidence-producer',
+ paths: [
+ ['src/main/runtime/orca-runtime-apply-tracked-pty-title.ts', 2],
+ ['src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts', 2],
+ ['src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts', 2],
+ ['src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts', 2],
+ ['src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts', 2],
+ ['src/main/runtime/runtime-terminal-agent-status-query.ts', 3],
+ ['src/main/runtime/runtime-worktree-status-projection.ts', 4],
+ ['src/main/runtime/terminal-wait-detection.ts', 2],
+ ['src/renderer/src/components/terminal-pane/agent-completion-title-observer.ts', 2],
+ ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 4],
+ ['src/renderer/src/components/terminal-pane/pty-output-title-observer.ts', 2],
+ ['src/shared/terminal-output-side-effects.ts', 3]
+ ]
+ },
+ {
+ helper: 'detectAgentStatusFromTitle',
+ classification: 'action-consumer',
+ paths: [
+ ['src/renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts', 2],
+ [
+ 'src/renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts',
+ 3
+ ],
+ ['src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts', 3]
+ ]
+ },
+ {
+ helper: 'detectAgentStatusFromTitle',
+ classification: 'parser-implementation',
+ paths: [
+ ['src/renderer/src/components/terminal-pane/title-agent-identity.ts', 2],
+ 'src/renderer/src/lib/agent-status.ts',
+ ['src/renderer/src/lib/pane-agent-evidence.ts', 3],
+ ['src/shared/agent-decorative-title-signature.ts', 2],
+ 'src/shared/agent-detection.ts',
+ ['src/shared/agent-title-owner.ts', 2],
+ ['src/shared/agent-title-status.ts', 6]
+ ]
+ },
+ {
+ helper: 'resolveAgentTypeFromTerminalTitle',
+ classification: 'identity-consumer',
+ paths: [
+ ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2],
+ 'src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts',
+ ['src/renderer/src/lib/worktree-status.ts', 2]
+ ]
+ },
+ {
+ helper: 'resolvePaneAgentIdentity',
+ classification: 'parser-implementation',
+ paths: ['src/shared/pane-agent-identity-resolver.ts']
+ },
+ {
+ helper: 'resolvePaneAgentIdentity',
+ classification: 'identity-consumer',
+ paths: [['src/shared/published-pane-agent-identity.ts', 2]]
+ },
+ {
+ helper: 'resolveCanonicalPaneAgentIdentity',
+ classification: 'parser-implementation',
+ paths: ['src/shared/pane-agent-identity-adapter.ts']
+ },
+ {
+ helper: 'resolveCanonicalPaneAgentIdentity',
+ classification: 'identity-consumer',
+ paths: [['src/shared/agent-status-identity.ts', 2]]
+ },
+ {
+ helper: 'resolveCanonicalPaneAgentIdentity',
+ classification: 'identity-consumer',
+ paths: [['src/shared/terminal-title-agent-type.ts', 2]]
+ },
+ {
+ helper: 'resolvePublishedPaneAgentIdentity',
+ classification: 'parser-implementation',
+ paths: [
+ 'src/shared/published-pane-agent-identity.ts',
+ ['src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts', 2]
+ ]
}
]
diff --git a/src/shared/pane-agent-identity-resolver.test.ts b/src/shared/pane-agent-identity-resolver.test.ts
index e28fb0cbd22..2f14a54976a 100644
--- a/src/shared/pane-agent-identity-resolver.test.ts
+++ b/src/shared/pane-agent-identity-resolver.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
+import { PANE_AGENT_SOURCE_RANK } from './pane-agent-identity-adapter'
import {
PANE_AGENT_EVIDENCE_SOURCES,
type PaneAgentEvidence,
@@ -11,6 +12,14 @@ const resolve = (evidence: PaneAgentEvidence[], extra = {}) =>
const H = 'authority-a'
describe('resolvePaneAgentIdentity', () => {
+ it('keeps every evidence source ranked exactly once', () => {
+ expect(PANE_AGENT_SOURCE_RANK).toBe(PANE_AGENT_EVIDENCE_SOURCES)
+ expect(new Set(PANE_AGENT_SOURCE_RANK).size).toBe(PANE_AGENT_SOURCE_RANK.length)
+ for (const source of PANE_AGENT_EVIDENCE_SOURCES) {
+ expect(PANE_AGENT_SOURCE_RANK.indexOf(source)).toBeGreaterThanOrEqual(0)
+ }
+ })
+
describe('a display title is the last thing consulted', () => {
it.each(PANE_AGENT_EVIDENCE_SOURCES.filter((s) => s !== 'title' && s !== 'sibling'))(
'lets %s outrank a conflicting title',
@@ -153,6 +162,17 @@ describe('resolvePaneAgentIdentity', () => {
})
})
+ it('fails loudly when an evidence source is missing from the rank', () => {
+ expect(() =>
+ resolve([
+ {
+ source: 'future-source' as PaneAgentEvidence['source'],
+ agent: 'codex'
+ }
+ ])
+ ).toThrow('Unknown pane-agent evidence source')
+ })
+
describe('input order does not decide the answer', () => {
it('resolves the same regardless of how evidence is listed', () => {
const evidence: PaneAgentEvidence[] = [
diff --git a/src/shared/pane-agent-identity-resolver.ts b/src/shared/pane-agent-identity-resolver.ts
index 98342963415..4b03c5de16d 100644
--- a/src/shared/pane-agent-identity-resolver.ts
+++ b/src/shared/pane-agent-identity-resolver.ts
@@ -1,5 +1,10 @@
+import { resolveCanonicalPaneAgentEvidence } from './pane-agent-identity-adapter'
+import type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources'
import type { TuiAgent } from './tui-agent'
+export { PANE_AGENT_EVIDENCE_SOURCES } from './pane-agent-evidence-sources'
+export type { PaneAgentEvidenceSource } from './pane-agent-evidence-sources'
+
/**
* One place that answers "which agent is in this pane".
*
@@ -23,27 +28,6 @@ import type { TuiAgent } from './tui-agent'
* launch, a recognized command at a shell prompt, a host-confirmed foreground change, a new
* provider session. Never by a title changing, and never by transport loss.
*/
-export const PANE_AGENT_EVIDENCE_SOURCES = [
- /** A live provider hook for a turn in progress. The agent is running and said so. */
- 'live-hook',
- /** The pane's foreground process, as read on the execution host. */
- 'process',
- /** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. */
- 'launch',
- /** A provider hook from a turn that finished. Still authoritative about identity. */
- 'completed-hook',
- /** A sleeping session record restored for this pane. */
- 'sleeping-session',
- /** Another pane in the same tab. Tab-level surfaces only; never pane-scoped routing. */
- 'sibling',
- /** Parsed from the terminal title. A decoration channel; anyone can type an agent's name. */
- 'title'
-] as const
-export type PaneAgentEvidenceSource = (typeof PANE_AGENT_EVIDENCE_SOURCES)[number]
-
-/** Authority order, strongest first. Position here is the ONLY place precedence is expressed. */
-const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES
-
/**
* Which agent run a piece of evidence belongs to.
*
@@ -112,52 +96,5 @@ export type PaneAgentIdentity = {
export function resolvePaneAgentIdentity(
input: PaneAgentIdentityInput
): PaneAgentIdentity {
- const superseded: PaneAgentEvidenceSource[] = []
- const floor = input.minimumSource
- ? SOURCE_RANK.indexOf(input.minimumSource)
- : Number.MAX_SAFE_INTEGER
-
- const eligible = input.evidence.filter((item) => {
- if (item.source === 'sibling' && input.allowSibling !== true) {
- return false
- }
- // Why the floor: an action consumer must not be able to act on a title, at any rank. Dropping
- // the evidence entirely rather than ranking it lower makes misuse impossible rather than
- // unlikely — a caller cannot accidentally consult it by reordering.
- if (SOURCE_RANK.indexOf(item.source) > floor) {
- return false
- }
- if (input.currentRun === undefined || item.run === undefined) {
- // Why eligible: absence means "this peer does not publish run keys", not "this is stale".
- // Treating unknown as superseded would blank every row from an older host.
- return true
- }
- if (item.run.authorityId !== input.currentRun.authorityId) {
- // Why eligible and NOT superseded: runs from different authorities are incomparable, not
- // older. A restarted main counts from its own floor, so `incarnation` alone would falsely
- // equate unrelated runs. Incomparable evidence is treated as unknown, like an absent key.
- return true
- }
- if (item.run.incarnation === input.currentRun.incarnation) {
- return true
- }
- superseded.push(item.source)
- return false
- })
-
- for (const source of SOURCE_RANK) {
- const matches = eligible.filter((item) => item.source === source)
- if (matches.length === 0) {
- continue
- }
- const agents = new Set(matches.map((item) => item.agent))
- if (agents.size > 1) {
- // Why null and not the first: two observations of the same class naming different agents is
- // a genuine conflict, and picking one would make the answer depend on array order — the very
- // property this resolver exists to remove. Fall through to nothing rather than guess.
- return { agent: null, source: null, ambiguousAt: source, supersededSources: superseded }
- }
- return { agent: matches[0].agent, source, supersededSources: superseded }
- }
- return { agent: null, source: null, supersededSources: superseded }
+ return resolveCanonicalPaneAgentEvidence(input)
}
diff --git a/src/shared/pane-agent-identity-surface-inventory.test.ts b/src/shared/pane-agent-identity-surface-inventory.test.ts
new file mode 100644
index 00000000000..77d9edb3b84
--- /dev/null
+++ b/src/shared/pane-agent-identity-surface-inventory.test.ts
@@ -0,0 +1,284 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { glob } from 'tinyglobby'
+import { isTestFile, stripComments } from './source-scan/source-tree-scan'
+
+/**
+ * Surface half of the identity inventory ratchet: every consumer decision point from the closed
+ * 65-row inventory (rows 32–65 — the direct title/native-chat selectors, tab projections, mobile
+ * sync graph, lifecycle selectors, status/OSC ingress, worktree status, attention, and
+ * title-reset paths) is pinned to a marker symbol in its file. The helper-name census
+ * (`pane-agent-identity-inventory.test.ts`) is necessary but not sufficient — these files reach
+ * identity through direct reads a name census cannot see. Moving or renaming a marker means the
+ * inventory row must be re-classified, deliberately, before review.
+ */
+
+type SurfaceRow = {
+ /** Row number in the closed consumer inventory. */
+ row: number
+ path: string
+ marker: string
+}
+
+const SURFACE_ROWS: readonly SurfaceRow[] = [
+ {
+ row: 32,
+ path: 'src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts',
+ marker: 'resolveNativeChatLeafTitleAgent'
+ },
+ {
+ row: 32,
+ path: 'src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts',
+ marker: 'resolveNativeChatLeafTitleAgent'
+ },
+ {
+ row: 33,
+ path: 'src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts',
+ marker: 'installPaneAgentIdentity'
+ },
+ { row: 34, path: 'src/main/runtime/orchestration/groups.ts', marker: 'terminalIsAgent' },
+ {
+ row: 35,
+ path: 'src/renderer/src/lib/active-agent-note-target.ts',
+ marker: 'getActiveTerminalNoteTarget'
+ },
+ {
+ row: 36,
+ path: 'src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts',
+ marker: 'resolveProtectedMultilinePasteOptionsForPane'
+ },
+ {
+ row: 37,
+ path: 'src/renderer/src/components/terminal-pane/command-code-output-ownership.ts',
+ marker: 'canCommandCodeOutputOwnPane'
+ },
+ { row: 38, path: 'src/renderer/src/lib/agent-ready-wait.ts', marker: 'waitForAgentReady' },
+ {
+ row: 39,
+ path: 'src/renderer/src/lib/agent-paste-draft.ts',
+ marker: 'getSettingsForAgentTabRuntimeOwner'
+ },
+ {
+ row: 40,
+ path: 'src/renderer/src/lib/agent-followup-delivery.ts',
+ marker: 'sendFollowupPromptWhenAgentReady'
+ },
+ {
+ row: 41,
+ path: 'src/renderer/src/lib/codex-session-restart.ts',
+ marker: 'markLiveCodexSessionsForRestart'
+ },
+ {
+ row: 41,
+ path: 'src/renderer/src/lib/codex-pane-restart-eligibility.ts',
+ marker: 'isCodexForegroundProcess'
+ },
+ {
+ row: 42,
+ path: 'src/renderer/src/components/native-chat/native-chat-availability.ts',
+ marker: 'canToggleNativeChat'
+ },
+ {
+ row: 43,
+ path: 'src/renderer/src/components/native-chat/native-chat-pane-resolution.ts',
+ marker: 'resolveNativeChatSession'
+ },
+ {
+ row: 44,
+ path: 'src/renderer/src/components/terminal-pane/terminal-agent-session-continuation.ts',
+ marker: 'canContinueAgentSessionInNewSession'
+ },
+ {
+ row: 45,
+ path: 'src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts',
+ marker: 'prepareAgentSessionForkFromPane'
+ },
+ {
+ row: 46,
+ path: 'src/renderer/src/components/terminal-pane/agent-interrupt-inference.ts',
+ marker: 'isPlainEscapeKeyEvent'
+ },
+ {
+ row: 46,
+ path: 'src/renderer/src/components/terminal-pane/agent-question-answered-inference.ts',
+ marker: 'inferQuestionAnsweredFromCurrentStatus'
+ },
+ {
+ row: 47,
+ path: 'src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts',
+ marker: 'resolvePaneKeyboardProtocolAgent'
+ },
+ {
+ row: 47,
+ path: 'src/renderer/src/components/terminal-pane/terminal-pane-manager-options.ts',
+ marker: 'resolvePaneKeyboardProtocolAgent'
+ },
+ {
+ row: 48,
+ path: 'src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts',
+ marker: 'selectTabAgentTypesByTabId'
+ },
+ {
+ row: 49,
+ path: 'src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts',
+ marker: 'createTerminalTabAgentTypeSelector'
+ },
+ {
+ row: 50,
+ path: 'src/renderer/src/lib/tab-agent-status-index.ts',
+ marker: 'selectLiveTabAgentPanes'
+ },
+ {
+ row: 51,
+ path: 'src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts',
+ marker: 'resolveTerminalTabActivityStatus'
+ },
+ {
+ row: 52,
+ path: 'src/renderer/src/lib/workspace-tab-agent-metadata.ts',
+ marker: 'maxAgentActivityAt'
+ },
+ {
+ row: 52,
+ path: 'src/renderer/src/lib/workspace-tab-palette-entry-builder.ts',
+ marker: 'buildSearchableWorkspaceTabEntries'
+ },
+ {
+ row: 53,
+ path: 'src/renderer/src/lib/running-agent-targets.ts',
+ marker: 'deriveRunningAgentSendTargets'
+ },
+ {
+ row: 54,
+ path: 'src/renderer/src/runtime/sync-runtime-graph.ts',
+ marker: 'buildMobileSessionTabSnapshots'
+ },
+ {
+ row: 55,
+ path: 'src/renderer/src/lib/agent-hibernation-pane-eligibility.ts',
+ marker: 'toRuntimePtyId'
+ },
+ {
+ row: 56,
+ path: 'src/renderer/src/lib/resume-sleeping-agent-session.ts',
+ marker: 'resumeSleepingAgentSessionsForWorktree'
+ },
+ {
+ row: 57,
+ path: 'src/renderer/src/lib/automation-session-reuse.ts',
+ marker: 'findReusableAutomationSession'
+ },
+ {
+ row: 58,
+ path: 'src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts',
+ marker: 'bindBuildColdRestoreAgentResumeStartup'
+ },
+ {
+ row: 59,
+ path: 'src/main/agent-hooks/server/server-authority-evidence.ts',
+ marker: 'recordCurrentAuthorityObservation'
+ },
+ {
+ row: 59,
+ path: 'src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts',
+ marker: 'resolvePaneAgentIdentityField'
+ },
+ {
+ row: 59,
+ path: 'src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts',
+ marker: 'createAgentStatusEventApplicator'
+ },
+ {
+ row: 59,
+ path: 'src/renderer/src/store/slices/agent-status-authority-actions.ts',
+ marker: 'transferAgentPaneAuthority'
+ },
+ {
+ row: 59,
+ path: 'src/renderer/src/store/slices/pane-foreground-agent.ts',
+ marker: 'createPaneForegroundAgentSlice'
+ },
+ {
+ row: 59,
+ path: 'src/renderer/src/hooks/ipc-events/agent-status-routing.ts',
+ marker: 'isAgentStatusForRecentlyClosedTab'
+ },
+ {
+ row: 60,
+ path: 'src/renderer/src/components/terminal-pane/pty-connection/title-spawn-bell.ts',
+ marker: 'installTitleSpawnBell'
+ },
+ { row: 61, path: 'src/renderer/src/lib/worktree-status.ts', marker: 'getWorktreeStatus' },
+ {
+ row: 62,
+ path: 'src/main/runtime/runtime-worktree-status-projection.ts',
+ marker: 'getLeafWorktreeStatus'
+ },
+ {
+ row: 63,
+ path: 'src/renderer/src/components/sidebar/smart-attention.ts',
+ marker: 'buildAttentionByWorktree'
+ },
+ {
+ row: 64,
+ path: 'src/renderer/src/components/status-bar/workspace-space-presentation.ts',
+ marker: 'countWorkspaceSpaceActiveAgents'
+ },
+ { row: 65, path: 'src/renderer/src/store/slices/terminal-helpers.ts', marker: 'getResetTitle' },
+ {
+ row: 6,
+ path: 'src/renderer/src/runtime/web-session-tabs-sync.ts',
+ marker: 'applyWebSessionTabs'
+ }
+]
+
+describe('pane agent identity surface inventory (rows 6, 32–65)', () => {
+ it('every pinned surface still carries its marker symbol', () => {
+ for (const row of SURFACE_ROWS) {
+ const source = stripComments(readFileSync(join(process.cwd(), row.path), 'utf8'))
+ expect({ row: row.row, path: row.path, hasMarker: source.includes(row.marker) }).toEqual({
+ row: row.row,
+ path: row.path,
+ hasMarker: true
+ })
+ }
+ })
+})
+
+/**
+ * Identity-observation rebind audit. Advancing a pane incarnation without a positive replacement
+ * proof is how a legitimate reclaim and a stale-hook bug get conflated (see
+ * `PaneReplacementProof` in pane-agent-identity-adapter.ts). Every existing sequencer `rebind`
+ * call is pinned here by file and count: today they are the retired-pane `restart` disposition
+ * (three ingress paths) and the renderer pane-key transfer. Adding a rebind call, or changing
+ * these, requires updating this audit — and per the migration plan, a `replacementProof`.
+ */
+const IDENTITY_SEQUENCER_REBIND_RE = /\b(?:observations|rendererAgentStatusObservations)\.rebind\(/g
+
+const EXPECTED_REBIND_SITES: readonly (readonly [path: string, occurrences: number])[] = [
+ ['src/main/agent-hooks/server/server-ingest-normalization.ts', 1],
+ ['src/main/agent-hooks/server/server-ingest-remote.ts', 1],
+ ['src/main/agent-hooks/server/server-lifecycle.ts', 1],
+ ['src/renderer/src/store/slices/agent-status-authority-actions.ts', 1]
+]
+
+describe('identity observation rebind audit', () => {
+ it('pins every identity-sequencer rebind call site by file and count', async () => {
+ const files = await glob(['src/**/*.{ts,tsx}', 'mobile/src/**/*.{ts,tsx}'], {
+ ignore: ['**/*.test.*', '**/*.spec.*']
+ })
+ const actual: [string, number][] = []
+ for (const path of files.sort()) {
+ if (isTestFile(path)) {
+ continue
+ }
+ const source = stripComments(readFileSync(join(process.cwd(), path), 'utf8'))
+ const occurrences = source.match(IDENTITY_SEQUENCER_REBIND_RE)?.length ?? 0
+ if (occurrences > 0) {
+ actual.push([path, occurrences])
+ }
+ }
+ expect(actual).toEqual(EXPECTED_REBIND_SITES.map((site) => [...site]))
+ }, 30_000)
+})
diff --git a/src/shared/pane-agent-identity-title-corpus.test.ts b/src/shared/pane-agent-identity-title-corpus.test.ts
new file mode 100644
index 00000000000..f24cd431065
--- /dev/null
+++ b/src/shared/pane-agent-identity-title-corpus.test.ts
@@ -0,0 +1,156 @@
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { createHash, randomBytes } from 'node:crypto'
+import { describe, expect, it } from 'vitest'
+import { collectAgentTitleEvidence } from './agent-title-evidence'
+import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter'
+import type { TuiAgent } from './tui-agent'
+
+/**
+ * Title regression gates for the identity-ladder migration.
+ *
+ * Two layers: a controlled fixture table that always runs (CI-safe), and a local characterization
+ * gate over the machine's real recorded corpus. The corpus gate is not a CI prerequisite tied to
+ * one developer's home — when no history exists it reports `corpus unavailable — skipped`
+ * explicitly, never a silently green zero-title run. Raw titles never reach logs or failure
+ * output; changed titles are reported as salted hashes plus old/new agent summaries only.
+ */
+
+const RECORDED_HISTORY_DIR = 'terminal-history'
+const QUARANTINE_DIR = '.recovery-quarantine'
+
+function orcaAppSupportCandidates(): string[] {
+ if (process.platform === 'darwin') {
+ return [join(homedir(), 'Library', 'Application Support', 'Orca')]
+ }
+ if (process.platform === 'win32') {
+ return [join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Orca')]
+ }
+ return [
+ join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'Orca'),
+ join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'Orca')
+ ]
+}
+
+/** Deliberately shallow: `terminal-history//checkpoint.json` only, with the hidden
+ * quarantine subtree excluded BY NAME so a future recursive rewrite cannot silently turn
+ * quarantined recovery data into product regressions. */
+function loadRecordedTitleCorpus(): { checkpointCount: number; titles: string[] } | null {
+ const root = orcaAppSupportCandidates()
+ .map((candidate) => join(candidate, RECORDED_HISTORY_DIR))
+ .find((candidate) => existsSync(candidate))
+ if (!root) {
+ return null
+ }
+ let checkpointCount = 0
+ const titles = new Set()
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
+ if (!entry.isDirectory() || entry.name === QUARANTINE_DIR || entry.name.startsWith('.')) {
+ continue
+ }
+ const checkpointPath = join(root, entry.name, 'checkpoint.json')
+ if (!existsSync(checkpointPath)) {
+ continue
+ }
+ const parsed: unknown = JSON.parse(readFileSync(checkpointPath, 'utf8'))
+ checkpointCount += 1
+ const lastTitle = (parsed as { lastTitle?: unknown }).lastTitle
+ if (typeof lastTitle === 'string' && lastTitle.length > 0) {
+ titles.add(lastTitle)
+ }
+ }
+ return { checkpointCount, titles: [...titles] }
+}
+
+/** What the canonical adapter answers when a title is all a pane has (the uncovered lane). */
+function canonicalTitleOnlyAgent(title: string): TuiAgent | null {
+ return resolveCanonicalPaneAgentIdentity({ title }).agent
+}
+
+describe('controlled title fixtures (always run)', () => {
+ const FIXTURES: readonly { name: string; title: string; expected: TuiAgent | null }[] = [
+ {
+ name: 'mandatory adversarial owner suffix beats the agent names in task text',
+ title: 'STA-4011 Linux Antigravity Commit Messages - grok',
+ expected: 'grok'
+ },
+ {
+ name: 'task text mentioning other agents is not identity',
+ title: 'Compare Antigravity with Gemini 3.7 Flash',
+ expected: null
+ },
+ {
+ name: 'owner suffix still answers over mentioned agents',
+ title: 'Compare Antigravity with Gemini 3.7 Flash… - grok',
+ expected: 'grok'
+ },
+ { name: 'Claude status sigil is a vendor marker', title: '✳', expected: 'claude' },
+ { name: 'Claude management screen is not identity', title: 'claude agents', expected: null },
+ { name: 'a shell title names no agent', title: 'zsh', expected: null },
+ { name: 'a default worktree-ish title names no agent', title: 'my-claude-fix', expected: null },
+ {
+ name: 'conflicting vendor markers resolve to nothing',
+ title: '✳ | ✦ two sigils',
+ expected: null
+ },
+ {
+ name: 'conflicting anchored names resolve to nothing',
+ title: 'OC | something… - grok',
+ expected: null
+ },
+ { name: 'a bare Pi title anchors as Pi', title: 'pi', expected: 'pi' },
+ { name: 'an OMP status title anchors as OMP', title: 'omp ready', expected: 'omp' },
+ {
+ // Wrapper-frame π/OMP separators are handled by the synthetic-title path, not this
+ // evidence parser; pinned so a parser change here is a deliberate decision.
+ name: 'a π wrapper frame is declined by the evidence parser',
+ title: 'π : ready',
+ expected: null
+ }
+ ]
+
+ for (const fixture of FIXTURES) {
+ it(fixture.name, () => {
+ expect(collectAgentTitleEvidence(fixture.title).agent).toBe(fixture.expected)
+ // The adapter's title-only lane must give the very same answer — phase 1 changes no
+ // parser semantics, only provenance.
+ expect(canonicalTitleOnlyAgent(fixture.title)).toBe(fixture.expected)
+ })
+ }
+})
+
+describe('recorded title corpus characterization (local gate)', () => {
+ it('the canonical title-only lane matches the shipped parser on every recorded title', (ctx) => {
+ const corpus = loadRecordedTitleCorpus()
+ if (corpus === null) {
+ console.info('corpus unavailable — skipped (no recorded terminal history on this machine)')
+ ctx.skip()
+ return
+ }
+ // A machine WITH history must never pass on an empty read — that would be a silently green
+ // zero-title run, not a characterization.
+ expect(corpus.checkpointCount).toBeGreaterThan(0)
+ expect(corpus.titles.length).toBeGreaterThan(0)
+ console.info(
+ `corpus: ${corpus.checkpointCount} checkpoints, ${corpus.titles.length} distinct titles`
+ )
+
+ const salt = randomBytes(16).toString('hex')
+ const changed: { titleHash: string; oldAgent: string | null; newAgent: string | null }[] = []
+ for (const title of corpus.titles) {
+ const oldAgent = collectAgentTitleEvidence(title).agent
+ const newAgent = canonicalTitleOnlyAgent(title)
+ if (oldAgent !== newAgent) {
+ changed.push({
+ titleHash: createHash('sha256').update(`${salt}:${title}`).digest('hex').slice(0, 16),
+ oldAgent,
+ newAgent
+ })
+ }
+ }
+ // Report hashes and agent summaries only; a reviewer who needs the raw value inspects the
+ // protected corpus on the machine that owns it.
+ expect(changed).toEqual([])
+ }, 60_000)
+})
diff --git a/src/shared/pane-agent-owner.test.ts b/src/shared/pane-agent-owner.test.ts
index 13cfd217c45..b0d61801392 100644
--- a/src/shared/pane-agent-owner.test.ts
+++ b/src/shared/pane-agent-owner.test.ts
@@ -37,6 +37,42 @@ describe('resolvePaneAgentOwner', () => {
).toBe('omp')
})
+ it('preserves the pre-tranche precedence for every conflicting owner tier', () => {
+ expect(
+ resolvePaneAgentOwnerRecord({
+ launchAgent: 'claude',
+ hookAgent: 'codex',
+ siblingHookAgent: 'gemini',
+ completedHookAgent: 'pi',
+ sleepingSessionAgent: 'omp'
+ })
+ ).toEqual({ agent: 'claude', ownerIsLaunch: true })
+ expect(
+ resolvePaneAgentOwnerRecord({
+ hookAgent: 'claude',
+ siblingHookAgent: 'codex',
+ completedHookAgent: 'gemini',
+ siblingCompletedHookAgent: 'pi',
+ sleepingSessionAgent: 'omp'
+ })
+ ).toEqual({ agent: 'claude', ownerIsLaunch: false })
+ expect(
+ resolvePaneAgentOwnerRecord({
+ siblingHookAgent: 'codex',
+ completedHookAgent: 'claude',
+ siblingCompletedHookAgent: 'gemini',
+ sleepingSessionAgent: 'omp'
+ })
+ ).toEqual({ agent: 'codex', ownerIsLaunch: false })
+ expect(
+ resolvePaneAgentOwnerRecord({
+ completedHookAgent: 'claude',
+ siblingCompletedHookAgent: 'codex',
+ sleepingSessionAgent: 'gemini'
+ })
+ ).toEqual({ agent: 'claude', ownerIsLaunch: false })
+ })
+
it('returns null when no owner evidence exists', () => {
expect(resolvePaneAgentOwner({})).toBeNull()
expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull()
diff --git a/src/shared/pane-agent-owner.ts b/src/shared/pane-agent-owner.ts
index 31b878d67ec..5b5563670e8 100644
--- a/src/shared/pane-agent-owner.ts
+++ b/src/shared/pane-agent-owner.ts
@@ -48,9 +48,10 @@ const PANE_OWNER_RANK: readonly {
]
/**
- * The single authoritative resolver for "which agent owns this pane", shared by
- * the tab-icon resolver, the terminal-pane display/renderer owner, and the
- * mirrored-tab title owner so they cannot drift apart.
+ * Compatibility owner lookup shared by the existing consumer surfaces.
+ *
+ * Tranche 0 intentionally preserves this pre-migration precedence byte-for-byte; switching
+ * these consumers to canonical evidence belongs to tranche 1.
*
* Why this precedence: launch intent is the authoritative bootstrap before any
* process signal exists, so it leads. Once launch metadata is gone — a mirrored
diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts
index cdbce788806..4a000f8be9b 100644
--- a/src/shared/terminal-title-agent-type.ts
+++ b/src/shared/terminal-title-agent-type.ts
@@ -10,6 +10,7 @@ import {
getPiCompatibleSyntheticAgentLabel,
isLegacyPiCompatibleTitle
} from './pi-compatible-synthetic-title'
+import { resolveCanonicalPaneAgentIdentity } from './pane-agent-identity-adapter'
import { memoizeTitleClassification } from './terminal-title-classification-memo'
import type { TuiAgent } from './tui-agent'
@@ -217,10 +218,10 @@ function computeAgentLabel(title: string): string | null {
return null
}
-// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for
-// agents whose foreground PROCESS name isn't self-identifying (Claude Code runs
-// as `node`, but its "✳ Claude Code" title resolves here). Agents whose process
-// name already matches (codex, etc.) never reach this path.
+/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */
+export const getAgentLabel: (title: string) => string | null =
+ memoizeTitleClassification(computeAgentLabel)
+
const TITLE_LABEL_TO_AGENT: Partial> = {
'Claude Code': 'claude',
OpenClaude: 'openclaude',
@@ -240,10 +241,6 @@ const TITLE_LABEL_TO_AGENT: Partial> = {
OMP: 'omp'
}
-/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */
-export const getAgentLabel: (title: string) => string | null =
- memoizeTitleClassification(computeAgentLabel)
-
function hasGenericClaudeStatusPrefix(title: string): boolean {
return (
containsAgentSpinnerGlyph(title) ||
@@ -266,7 +263,13 @@ function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null):
export function resolveTerminalTitleAgentType(title: string): TuiAgent | null {
const label = getAgentLabel(title)
- return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
+ const parsed = label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
+ return resolveCanonicalPaneAgentIdentity({
+ title,
+ // Preserve this public title-parser adapter's historical answer; pane identity
+ // consumers pass raw titles to the canonical resolver and enforce its fence.
+ uncoveredFallback: { agent: parsed, titleOnly: false }
+ }).agent
}
/**
@@ -283,6 +286,6 @@ function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null {
return titleAgent
}
-/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */
+/** Pure in `title` — memoized so repeated selector reads skip the canonical/title parse. */
export const resolveExplicitTerminalTitleAgentType: (title: string) => TuiAgent | null =
memoizeTitleClassification(computeExplicitTerminalTitleAgentType)