diff --git a/src/shared/published-pane-agent-identity.test.ts b/src/shared/published-pane-agent-identity.test.ts index 2a0a5ef3418..acca2033b53 100644 --- a/src/shared/published-pane-agent-identity.test.ts +++ b/src/shared/published-pane-agent-identity.test.ts @@ -30,10 +30,17 @@ describe('resolvePublishedPaneAgentIdentity', () => { expect(resolve({ launchAgent: 'claude', foregroundAgent: 'codex' })).toBe('codex') }) - it('falls back to the title when the host has no process or launch evidence', () => { - // Hook-less agents over SSH surface only a decorated title; dropping it would make those - // panes unaddressable. It is last, not absent. - expect(resolve({ title: '✳ Claude Code' })).toBe('claude') + it('publishes nothing from a title alone, even an unambiguous one', () => { + // Deliberate, and the sharpest trade in this change. What this publishes AUTHORIZES ACTIONS: + // orchestration routing uses it to pick which real agent pane receives a message. A title is + // a decoration channel, and a stale one outlives the agent that wrote it, so a pane whose only + // evidence is a parsed string publishes no identity and every action consumer fails closed. + // + // The cost: a hook-less agent over SSH that Orca did not launch, and whose foreground process + // the host cannot read, is not addressable by @agent. That is a real capability loss, accepted + // because a message delivered into the wrong agent's prompt is not recoverable and an + // undelivered one is — the sender sees zero recipients. + expect(resolve({ title: '✳ Claude Code' })).toBeUndefined() }) it('publishes nothing when the title names no agent unambiguously', () => { diff --git a/src/shared/published-pane-agent-identity.ts b/src/shared/published-pane-agent-identity.ts index 1531c447dbd..47a6e2c8bd0 100644 --- a/src/shared/published-pane-agent-identity.ts +++ b/src/shared/published-pane-agent-identity.ts @@ -8,10 +8,10 @@ import type { TuiAgent } from './tui-agent' * Kept out of the runtime class so it can be tested without one, and so routing, delivery and the * UI all read the same decision instead of each re-deriving it. * - * Title is included but ranks last, and contributes only when the evidence parser finds an - * unambiguous name. A task title that merely mentions an agent yields no title evidence at all, - * which is the whole point: "Switch Claude and Codex off the load balancer… - grok" is a Grok - * pane, and used to receive both `@claude` and `@codex`. + * Title is NOT consulted. What this publishes authorizes actions, and a terminal title is a + * decoration channel a user can type any agent's name into — "Switch Claude and Codex off the + * load balancer… - grok" is a Grok pane that used to receive both `@claude` and `@codex`. + * Identity therefore comes only from launch and foreground-process evidence the host owns. * * Returns undefined when nothing is known, and absence is published as absence. A caller that * authorizes an action must fail closed on it rather than falling back to parsing the title. @@ -24,6 +24,11 @@ export function resolvePublishedPaneAgentIdentity(args: { const titleAgent = args.title ? collectAgentTitleEvidence(args.title).agent : null return ( resolvePaneAgentIdentity({ + // Why the floor: what this publishes authorizes actions — orchestration routing decides + // which real agent pane receives a message. Ranking title last makes a bad delivery + // unlikely; refusing to see it makes one impossible. A pane whose only evidence is a + // parsed string publishes no identity, and every action consumer fails closed on absence. + minimumSource: 'launch', evidence: [ ...(args.foregroundAgent ? [{ source: 'process' as const, agent: args.foregroundAgent }]