From e986a7ba1a0623ed08bd61f2616f90cd3cf27efd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:14 -0700 Subject: [PATCH] feat(codex): surface nested subagents (#9637) * feat(codex): surface nested subagents * fix(codex): retire child rows on root stop * fix(codex): preserve nested agent state on restart * fix(codex): preserve nested agent identity * fix(codex): preserve subagents across relay restarts * chore(skills): refresh generated manifests * fix(codex): keep inferred interrupts terminal * chore(skills): record latest release snapshots --- src/main/agent-hooks/server.test.ts | 358 +++++++++++++++++- src/main/agent-hooks/server.ts | 78 +++- src/main/codex/codex-hook-identity.ts | 4 + src/main/codex/config-toml-trust.ts | 6 + .../codex/hook-service-trust-grant.test.ts | 2 +- .../codex/hook-service-wsl-runtime.test.ts | 2 + src/main/codex/hook-service.test.ts | 2 + src/main/codex/hook-service.ts | 4 + src/main/github/github-api-repository.ts | 7 +- .../dashboard/DashboardAgentRow.test.tsx | 8 + .../dashboard/DashboardAgentRow.tsx | 11 +- .../components/settings/AccountsPane.test.tsx | 5 +- .../sidebar/WorktreeCardAgents.test.tsx | 22 ++ .../sidebar/useWorktreeAgentRows.test.ts | 18 + .../worktree-card-compact-agent-row.tsx | 22 +- .../sidebar/worktree-subagent-child-rows.ts | 14 +- src/renderer/src/hooks/useIpcEvents.ts | 1 + .../agent-status-provider-session.test.ts | 26 ++ src/renderer/src/store/slices/agent-status.ts | 15 +- src/shared/agent-hook-listener.ts | 285 +++++++++++++- src/shared/agent-status-types.test.ts | 2 + src/shared/agent-status-types.ts | 26 +- src/shared/codex-subagent-roster.test.ts | 62 +++ src/shared/codex-subagent-roster.ts | 105 +++++ 24 files changed, 1019 insertions(+), 66 deletions(-) create mode 100644 src/shared/codex-subagent-roster.test.ts create mode 100644 src/shared/codex-subagent-roster.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 0a75205623c..6b4e12042c5 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -24,6 +24,11 @@ import { AGENT_STATUS_STALE_AFTER_MS, parseAgentStatusPayload } from '../../shared/agent-status-types' +import { + createHookListenerState, + normalizeHookPayload, + type HookListenerState +} from '../../shared/agent-hook-listener' import { makePaneKey } from '../../shared/stable-pane-id' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ @@ -89,7 +94,144 @@ afterEach(() => { }) describe('AgentHookServer listener replay', () => { - it('applies inferred interrupts through the cached status lifecycle', () => { + it('preserves Codex sibling and lead state across relay listener restarts', () => { + const server = new AgentHookServer() + const send = (state: HookListenerState, payload: Record): void => { + const event = normalizeHookPayload(state, 'codex', buildBody(payload), 'production') + if (!event) { + throw new Error('normalizeHookPayload rejected a known-good Codex fixture') + } + server.ingestRemote(event, 'conn-1') + } + + const initialRelay = createHookListenerState() + send(initialRelay, { + hook_event_name: 'UserPromptSubmit', + session_id: 'root-session', + prompt: 'coordinate reviewers', + model: 'gpt-5.4' + }) + for (const id of ['child-a', 'child-b']) { + send(initialRelay, { + hook_event_name: 'SubagentStart', + agent_id: id, + agent_type: 'reviewer', + model: 'gpt-5.4-mini' + }) + } + + const restartedRelay = createHookListenerState() + send(restartedRelay, { hook_event_name: 'SubagentStop', agent_id: 'child-a' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + model: 'gpt-5.4', + providerSession: { key: 'session_id', id: 'root-session' }, + subagents: [expect.objectContaining({ id: 'child-b' })] + }) + + send(restartedRelay, { + hook_event_name: 'Stop', + session_id: 'root-session', + model: 'gpt-5.4' + }) + const restartedAgain = createHookListenerState() + send(restartedAgain, { hook_event_name: 'SubagentStop', agent_id: 'child-b' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + model: 'gpt-5.4', + providerSession: { key: 'session_id', id: 'root-session' }, + subagents: undefined + }) + }) + + it('does not carry a remote Codex roster across connection cleanup', () => { + const server = new AgentHookServer() + const child = (id: string) => ({ + id, + state: 'working' as const, + startedAt: 1 + }) + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'SubagentStart', + toolAgentId: 'old-child', + payload: { + state: 'working', + prompt: '', + agentType: 'codex', + subagents: [child('old-child')] + } + }, + 'conn-1' + ) + + server.clearStatusEntriesForConnection('conn-1') + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'SubagentStart', + toolAgentId: 'new-child', + payload: { + state: 'working', + prompt: '', + agentType: 'codex', + subagents: [child('new-child')] + } + }, + 'conn-2' + ) + + expect(server.getStatusSnapshot()[0]?.subagents).toEqual([child('new-child')]) + }) + + it('retains root Codex identity when relay child events omit it', () => { + const server = new AgentHookServer() + const providerSession = { key: 'session_id' as const, id: 'root-session' } + + server.ingestRemote( + { + paneKey: PANE, + providerSession, + payload: { + state: 'working', + prompt: 'coordinate reviewers', + agentType: 'codex', + model: 'gpt-5.4' + } + }, + 'conn-1' + ) + server.ingestRemote( + { + paneKey: PANE, + toolAgentId: 'child-session', + payload: { state: 'waiting', prompt: 'coordinate reviewers', agentType: 'codex' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + model: 'gpt-5.4', + providerSession + }) + + server.ingestRemote( + { + paneKey: PANE, + hookEventName: 'SubagentStop', + toolAgentId: 'child-session', + payload: { state: 'done', prompt: 'coordinate reviewers', agentType: 'codex' } + }, + 'conn-1' + ) + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + model: 'gpt-5.4', + providerSession + }) + }) + + it('keeps Codex lead state terminal after an inferred interrupt', () => { vi.useFakeTimers() vi.setSystemTime(1_000) try { @@ -102,7 +244,13 @@ describe('AgentHookServer listener replay', () => { tabId: 'tab-1', worktreeId: 'wt-1', providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, - payload: { state: 'working', prompt: 'long task', agentType: 'codex' } + hookEventName: 'UserPromptSubmit', + payload: { + state: 'working', + prompt: 'long task', + agentType: 'codex', + model: 'gpt-5.6-sol' + } }, 'conn-1' ) @@ -138,6 +286,25 @@ describe('AgentHookServer listener replay', () => { payload: expect.objectContaining({ state: 'done', interrupted: true }) }) ) + + vi.setSystemTime(17_000) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + hookEventName: 'SubagentStop', + toolAgentId: 'delayed-child', + payload: { state: 'done', prompt: 'long task', agentType: 'codex' } + }, + 'conn-1' + ) + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + model: 'gpt-5.6-sol', + prompt: 'long task' + }) } finally { vi.useRealTimers() } @@ -4209,6 +4376,109 @@ describe('Claude hook normalization', () => { }) describe('Codex hook normalization', () => { + it('tracks nested subagents with their role, model, and lifecycle state', () => { + const root = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'UserPromptSubmit', + prompt: 'Coordinate the review', + model: 'gpt-5.4' + }), + 'production' + ) + expect(root?.payload.model).toBe('gpt-5.4') + + const started = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'SubagentStart', + session_id: 'child-session', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini' + }), + 'production' + ) + expect(started?.providerSession).toBeUndefined() + expect(started?.payload).toMatchObject({ + state: 'working', + prompt: 'Coordinate the review', + model: 'gpt-5.4', + subagents: [ + { + id: 'child-session', + agentType: 'reviewer', + model: 'gpt-5.4-mini', + state: 'working' + } + ] + }) + + const waiting = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PermissionRequest', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'git fetch' } + }), + 'production' + ) + expect(waiting?.payload.state).toBe('waiting') + expect(waiting?.payload.subagents?.[0].state).toBe('waiting') + + const workingAgain = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'git fetch' } + }), + 'production' + ) + expect(workingAgain?.payload.state).toBe('working') + expect(workingAgain?.payload.subagents?.[0].state).toBe('working') + + const rootStop = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), + 'production' + ) + expect(rootStop?.payload.state).toBe('done') + expect(rootStop?.payload.subagents).toBeUndefined() + + const resumedChild = _internals.normalizeHookPayload( + 'codex', + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: 'child-session', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test' } + }), + 'production' + ) + expect(resumedChild?.payload.state).toBe('working') + expect(resumedChild?.payload.subagents?.[0]).toMatchObject({ + id: 'child-session', + state: 'working' + }) + + const stopped = _internals.normalizeHookPayload( + 'codex', + buildBody({ hook_event_name: 'SubagentStop', agent_id: 'child-session' }), + 'production' + ) + expect(stopped?.payload.state).toBe('done') + expect(stopped?.payload.subagents).toBeUndefined() + }) + it('Stop carries last_assistant_message into lastAssistantMessage', () => { const result = _internals.normalizeHookPayload( 'codex', @@ -6243,6 +6513,90 @@ describe('Last-status persistence', () => { } }) + it('restores Codex child hierarchy and reaps unconfirmed children on the next root Stop', async () => { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = recentTs() + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: recentTs(-1000), + payload: { + state: 'working', + prompt: 'coordinate reviews', + agentType: 'codex', + model: 'gpt-5.4', + subagents: [ + { + id: '11111111-2222-4333-8444-555555555555', + state: 'working', + startedAt: receivedAt - 5000, + agentType: 'reviewer', + model: 'gpt-5.4-mini' + } + ] + } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + model: 'gpt-5.4', + subagents: [ + expect.objectContaining({ + id: '11111111-2222-4333-8444-555555555555', + model: 'gpt-5.4-mini' + }) + ] + }) + ]) + + await postHookEvent( + server, + buildBody({ + hook_event_name: 'PreToolUse', + agent_id: '11111111-2222-4333-8444-555555555555', + agent_type: 'reviewer', + model: 'gpt-5.4-mini', + tool_name: 'exec_command', + tool_input: { cmd: 'pnpm test' } + }), + '/hook/codex' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + model: 'gpt-5.4', + subagents: [expect.objectContaining({ model: 'gpt-5.4-mini' })] + }) + ]) + + await postHookEvent( + server, + buildBody({ hook_event_name: 'Stop', model: 'gpt-5.4' }), + '/hook/codex' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'done', model: 'gpt-5.4', subagents: undefined }) + ]) + } finally { + server.stop() + } + }) + it('treats a corrupt file as empty hydration without throwing', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) writeFileSync(lastStatusPath(), 'not-json{{', 'utf8') diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 62d588d5df5..ef201bfe567 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -18,14 +18,17 @@ import { hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, markClaudeLeadTurnInterrupted, + markCodexLeadTurnInterrupted, MAX_PANE_KEY_LEN, movePaneCacheState, normalizeHookPayload, parseFormEncodedBody, readRequestBody, + reconcileRemoteCodexState, resolveHookSource, preparePendingGrokResultDiscovery, seedClaudeSubagentRosterFromSnapshots, + seedCodexStateFromSnapshot, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -162,11 +165,11 @@ function dropHydratedIdleClaudeSubagents( ) { return payload } - const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working') + const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') // Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile. return { ...payload, - subagents: workingSubagents.length > 0 ? workingSubagents : undefined + subagents: activeSubagents.length > 0 ? activeSubagents : undefined } } @@ -271,7 +274,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta } } -// Why: OSC-only dedupe; omits `subagents` (OSC never carries them) so an OSC ping can't wipe the hook-cached roster. Don't reuse for hook comparisons. +// Why: OSC never carries model/children; omit both so an equivalent OSC ping preserves the hook-cached identity graph. function equivalentParsedAgentStatusPayload( a: ParsedAgentStatusPayload, b: ParsedAgentStatusPayload @@ -560,7 +563,7 @@ export class AgentHookServer { return false } // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. - if (payload.subagents?.some((subagent) => subagent.state === 'working')) { + if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { return false } @@ -568,6 +571,9 @@ export class AgentHookServer { if (agentType === 'claude') { markClaudeLeadTurnInterrupted(this.state, existing.paneKey) } + if (agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, existing.paneKey) + } const inferred = this.applyNormalizedStatus({ paneKey: existing.paneKey, tabId: existing.tabId, @@ -578,6 +584,7 @@ export class AgentHookServer { state: 'done', prompt: payload.prompt, agentType, + ...(payload.model ? { model: payload.model } : {}), interrupted: true, // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. ...(payload.subagents ? { subagents: payload.subagents } : {}) @@ -835,6 +842,43 @@ export class AgentHookServer { this.onAgentStatus?.(enriched) return enriched } + const stateReconciledPayload = + payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + ? { + ...payload, + payload: reconcileRemoteCodexState( + this.state, + payload.paneKey, + payload.hookEventName, + payload.toolAgentId, + payload.payload, + previous?.payload + ) + } + : payload + const previousCodexRoot = + stateReconciledPayload.payload.agentType === 'codex' && + stateReconciledPayload.toolAgentId && + previous?.payload.agentType === 'codex' + ? previous + : undefined + const preservedProviderSession = !stateReconciledPayload.providerSession + ? previousCodexRoot?.providerSession + : undefined + const preservedRootModel = !stateReconciledPayload.payload.model + ? previousCodexRoot?.payload.model + : undefined + // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. + const rootContextPreservingPayload = + preservedProviderSession || preservedRootModel + ? { + ...stateReconciledPayload, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: preservedRootModel + ? { ...stateReconciledPayload.payload, model: preservedRootModel } + : stateReconciledPayload.payload + } + : stateReconciledPayload const identity = resolveAgentStatusIdentity({ existing: previous ? { @@ -843,25 +887,25 @@ export class AgentHookServer { updatedAt: previous.receivedAt } : undefined, - incoming: payload.payload.agentType, + incoming: rootContextPreservingPayload.payload.agentType, now }) if ( previous && shouldSuppressInheritedTerminalStatus({ inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: payload.payload.state + incomingState: rootContextPreservingPayload.payload.state }) ) { return previous } const identityResolvedPayload = - identity.agentType === payload.payload.agentType - ? payload + identity.agentType === rootContextPreservingPayload.payload.agentType + ? rootContextPreservingPayload : { - ...payload, + ...rootContextPreservingPayload, payload: { - ...payload.payload, + ...rootContextPreservingPayload.payload, agentType: identity.agentType } } @@ -1598,8 +1642,14 @@ export class AgentHookServer { if (entry.connectionId !== normalizedConnectionId) { continue } - if (this.deleteStatusEntry(paneKey)) { + const deleted = this.deleteStatusEntry(paneKey) + if (deleted) { statusChanged = true + if (deleted.payload.agentType === 'codex') { + // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. + this.state.codexSubagentRosterByPaneKey.delete(paneKey) + this.state.codexLeadStateByPaneKey.delete(paneKey) + } } } if (statusChanged) { @@ -1840,8 +1890,10 @@ export class AgentHookServer { Math.max(previousWatermark ?? -1, entry.receivedAt) ) } - // Why: seed only working children across restart; a later full inventory reaps stale ones. - if (entry.payload.subagents) { + // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. + if (entry.payload.agentType === 'codex') { + seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) + } else if (entry.payload.agentType === 'claude' && entry.payload.subagents) { seedClaudeSubagentRosterFromSnapshots( this.state, resolvedPaneKey, diff --git a/src/main/codex/codex-hook-identity.ts b/src/main/codex/codex-hook-identity.ts index cc78a9acb76..7717ce469e2 100644 --- a/src/main/codex/codex-hook-identity.ts +++ b/src/main/codex/codex-hook-identity.ts @@ -8,6 +8,8 @@ import type { CodexEventLabel, CodexTrustEntry } from './config-toml-trust' export const CODEX_HOOK_EVENT_LABEL: Record = { SessionStart: 'session_start', UserPromptSubmit: 'user_prompt_submit', + SubagentStart: 'subagent_start', + SubagentStop: 'subagent_stop', PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use', @@ -19,6 +21,8 @@ export const CODEX_HOOK_EVENT_LABEL: Record = { export const CODEX_EVENT_NAME_BY_LABEL: Record = { session_start: 'SessionStart', user_prompt_submit: 'UserPromptSubmit', + subagent_start: 'SubagentStart', + subagent_stop: 'SubagentStop', pre_tool_use: 'PreToolUse', permission_request: 'PermissionRequest', post_tool_use: 'PostToolUse', diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index be53dae20ce..a4768730c5b 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -31,6 +31,8 @@ export type CodexEventLabel = | 'post_compact' | 'session_start' | 'user_prompt_submit' + | 'subagent_start' + | 'subagent_stop' | 'stop' export type CodexTrustEntry = { @@ -114,6 +116,8 @@ function matcherPatternForEvent( case 'pre_compact': case 'post_compact': case 'session_start': + case 'subagent_start': + case 'subagent_stop': return matcher } } @@ -315,6 +319,8 @@ function isCodexEventLabel(value: string): value is CodexEventLabel { value === 'post_compact' || value === 'session_start' || value === 'user_prompt_submit' || + value === 'subagent_start' || + value === 'subagent_stop' || value === 'stop' ) } diff --git a/src/main/codex/hook-service-trust-grant.test.ts b/src/main/codex/hook-service-trust-grant.test.ts index 42b81e02064..9e28dbbdc2a 100644 --- a/src/main/codex/hook-service-trust-grant.test.ts +++ b/src/main/codex/hook-service-trust-grant.test.ts @@ -174,7 +174,7 @@ describe('CodexHookService app-server trust grant lane', () => { timeoutSec: 10 }) expect(trustConfig).not.toContain(selfComputed) - expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(6) + expect(Object.keys(readCodexTrustGrantLedgerHome(managedHome)!.entries)).toHaveLength(8) }) it('keeps config byte-stable and skips the session on a repeat ledger hit', () => { diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index 28c230ff160..f56ea144937 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -33,6 +33,8 @@ const managedEvents = [ 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'SubagentStart', + 'SubagentStop', 'Stop' ] as const diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index 4546f9047cb..a2d764d259c 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -123,6 +123,8 @@ function localManagedCodexEvents(): string[] { 'PreToolUse', 'SessionStart', 'Stop', + 'SubagentStart', + 'SubagentStop', 'UserPromptSubmit' ] } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index a63886a6433..48a04fa9b38 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -89,6 +89,8 @@ const CODEX_EVENTS = [ 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'SubagentStart', + 'SubagentStop', 'Stop' ] as const @@ -112,6 +114,8 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> PreToolUse: CODEX_HOOK_EVENT_LABEL.PreToolUse!, PermissionRequest: CODEX_HOOK_EVENT_LABEL.PermissionRequest!, PostToolUse: CODEX_HOOK_EVENT_LABEL.PostToolUse!, + SubagentStart: CODEX_HOOK_EVENT_LABEL.SubagentStart!, + SubagentStop: CODEX_HOOK_EVENT_LABEL.SubagentStop!, Stop: CODEX_HOOK_EVENT_LABEL.Stop! } diff --git a/src/main/github/github-api-repository.ts b/src/main/github/github-api-repository.ts index 7ef11c9f8fb..08461f849e9 100644 --- a/src/main/github/github-api-repository.ts +++ b/src/main/github/github-api-repository.ts @@ -98,12 +98,7 @@ export async function getGitHubApiRepositoryForRemote( ): Promise { // Why: generic PR resolution prefers upstream, but this API represents the // caller-selected remote exactly (#7331). - const ownerRepo = await getOwnerRepoForRemote( - repoPath, - remoteName, - connectionId, - localGitOptions - ) + const ownerRepo = await getOwnerRepoForRemote(repoPath, remoteName, connectionId, localGitOptions) if (ownerRepo) { return { ...ownerRepo, host: 'github.com' } } diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 8ba545fd2b0..931409f13ba 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -146,6 +146,14 @@ describe('DashboardAgentRow', () => { expect(markup).not.toContain('You are working inside Orca') }) + it('shows the active model beside the agent label', () => { + const markup = renderRow(makeAgent({}, { model: 'gpt-5.4-mini' })) + + expect(markup).toContain('title="gpt-5.4-mini"') + expect(markup).toContain('>gpt-5.4-mini') + expect(classTokens(markup)).toContain('font-mono') + }) + it('uses the hover background as the focused-pane row highlight', () => { const markup = renderToStaticMarkup( diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 05c11660793..e6a47533118 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -158,6 +158,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ const prompt = getAgentRowPrimaryText(agent.entry) // Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled. const displayLabel = prompt || agentStateLabel(asDotState(agent.state)) + const model = agent.entry.model?.trim() ?? '' // Why: gate tool fields on 'working' — a stale tool line on a done row reads as still-running. const isWorking = agent.state === 'working' const toolName = isWorking ? (agent.entry.toolName?.trim() ?? '') : '' @@ -173,7 +174,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ ? `${formatAgentTypeLabel(agent.agentType)} - dispatched ${lineageChildCount} ${ lineageChildCount === 1 ? 'agent' : 'agents' }` - : formatAgentTypeLabel(agent.agentType) + : [formatAgentTypeLabel(agent.agentType), model].filter(Boolean).join(' · ') // Why: interrupted is a terminal outcome, so surface it in the leading state dot. const dotState: AgentDotState = isInterrupted ? 'interrupted' : asDotState(agent.state) const dotTooltipLabel = stateDotTooltipLabel(agent, dotState) @@ -283,6 +284,14 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ > {displayLabel} + {model && ( + + {model} + + )} {/* Why: "+N" badge shows the hidden child count when collapsed; redundant once children are expanded below. */} {hasChildDisclosure && !childAgentsExpanded && ( { it('selects the WSL account location under auto when the global project runtime is WSL', () => { // Why: navigator.userAgent is a read-only prototype getter, so shadow it with // a configurable own property and remove that shadow afterward to restore it. - const originalOwnUserAgent = Object.getOwnPropertyDescriptor( - globalThis.navigator, - 'userAgent' - ) + const originalOwnUserAgent = Object.getOwnPropertyDescriptor(globalThis.navigator, 'userAgent') Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', configurable: true diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 380bb9194c4..d5591b9e9d5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -357,6 +357,28 @@ describe('WorktreeCardAgents', () => { expect(markup).not.toContain('Prompt cache expires') }) + it('does not repeat a subagent role used as its compact primary label', async () => { + const { CompactAgentRow } = await import('./worktree-card-compact-agents') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('reviewer') + expect(markup).not.toContain(' - Reviewer') + }) + it('marks only the focused agent row', async () => { mockAgentActivityDisplayMode = 'full' mockFocusedAgentPaneKey = 'tab-1:2' diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 5fdb22ef6b4..4d7ab9e84d6 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -712,6 +712,7 @@ describe('applyAgentRowLineage', () => { state: 'working', startedAt: 1500, agentType: 'general-purpose', + model: 'gpt-5.4-mini', description: 'Review loop' }, { id: 'r1', state: 'idle', startedAt: 1600, agentType: 'code-reviewer' } @@ -729,11 +730,13 @@ describe('applyAgentRowLineage', () => { expect(children[0]).toMatchObject({ state: 'working', agentType: 'general-purpose', + entry: { model: 'gpt-5.4-mini' }, activationPaneKey: PANE_KEY_1, startedAt: 1500 }) expect(children[0].entry.prompt).toBe('Review loop') expect(children[1]).toMatchObject({ state: 'idle', agentType: 'code-reviewer' }) + expect(children[1].entry.prompt).toBe('code-reviewer') const ordered = applyAgentRowLineage(rows) expect(ordered[0].paneKey).toBe(PANE_KEY_1) @@ -758,6 +761,21 @@ describe('applyAgentRowLineage', () => { expect(child?.state).toBe('idle') }) + it('surfaces a live subagent waiting state', () => { + const entry = makeEntry(PANE_KEY_1, 1000, { + state: 'waiting', + subagents: [{ id: 'child-1', state: 'waiting', startedAt: 1000, agentType: 'reviewer' }] + }) + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [entry], + retained: [], + now: 2000 + }) + + expect(rows.find((row) => row.rowSource === 'subagent')?.state).toBe('waiting') + }) + it('does not derive subagent child rows for retained snapshots', () => { const retained = makeRetained(PANE_KEY_1, 'wt-1', 1000, { entry: makeEntry(PANE_KEY_1, 1000, { diff --git a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx index 5282fe65422..7669abb32c2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-compact-agent-row.tsx @@ -46,7 +46,15 @@ function getCompactAgentSecondary(agent: DashboardAgentRowData): string { return toolName } } - return agent.entry.lastAssistantMessage?.trim() || formatAgentTypeLabel(agent.agentType) + const lastAssistantMessage = agent.entry.lastAssistantMessage?.trim() + if (lastAssistantMessage) { + return lastAssistantMessage + } + // Why: child rows without descriptions use their role as primary text; repeating its formatted label adds no information. + if (agent.rowSource === 'subagent' && agent.entry.prompt?.trim() === agent.agentType.trim()) { + return '' + } + return formatAgentTypeLabel(agent.agentType) } function getCompactAgentTime(agent: DashboardAgentRowData, now: number): string | null { @@ -111,6 +119,7 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ const primary = getCompactAgentPrimary(agent) const isLineageChild = agent.lineage?.depth === 1 const secondary = getCompactAgentSecondary(agent) + const model = agent.entry.model?.trim() ?? '' const shortTime = getCompactAgentTime(agent, now) const cacheTimer = usePromptCacheCountdownForPane(agent.paneKey, cacheTimerActive) @@ -201,6 +210,17 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({ )} + {model && ( + + {model} + + )} {hasChildDisclosure && !childAgentsExpanded && ( { - const working = subagent.state === 'working' && args.parentIsFresh + const activeState = args.parentIsFresh && subagent.state !== 'idle' ? subagent.state : undefined + const state = activeState ?? 'idle' const startedAt = subagent.startedAt > 0 ? subagent.startedAt : args.parentEntry.stateStartedAt const paneKey = subagentRowKey(args.parentEntry.paneKey, subagent.id) const entry: AgentStatusEntry = { - state: working ? 'working' : 'done', - prompt: subagent.description ?? '', + state: activeState ?? 'done', + prompt: subagent.description ?? subagent.agentType ?? '', updatedAt: args.parentEntry.updatedAt, stateStartedAt: startedAt, agentType: subagent.agentType, + model: subagent.model, paneKey, worktreeId: args.parentEntry.worktreeId, tabId: args.parentEntry.tabId, @@ -55,7 +57,7 @@ export function buildSubagentChildRows(args: { tab: args.tab, agentType: subagent.agentType ?? 'unknown', rowSource: 'subagent' as const, - state: working ? ('working' as const) : ('idle' as const), + state, activationPaneKey: args.parentEntry.paneKey, startedAt } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 4e2174cb403..a89132ad53e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2894,6 +2894,7 @@ export function useIpcEvents(): void { state: data.state, prompt: data.prompt, agentType: data.agentType, + model: data.model, toolName: data.toolName, toolInput: data.toolInput, // Why: the live AskUserQuestion prompt rides this field; omitting it drops the native question card on web/mobile. diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index 41f42e60bd6..d8169c49798 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -5,6 +5,32 @@ import { getProviderSessionClaimKey } from '../../lib/sleeping-agent-pane-owners import { createTestStore, makeTab } from './store-test-helpers' describe('recordAgentProviderSession', () => { + it('preserves the root session while a child permission hook moves Codex to waiting', () => { + const store = createTestStore() + const providerSession = { key: 'session_id' as const, id: 'root-session' } + + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'coordinate reviewers', agentType: 'codex' }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + undefined, + { providerSession } + ) + store.getState().setAgentStatus('tab-1:leaf-1', { + state: 'waiting', + prompt: 'coordinate reviewers', + agentType: 'codex', + subagents: [{ id: 'child-1', state: 'waiting', startedAt: 11 }] + }) + + expect(store.getState().agentStatusByPaneKey['tab-1:leaf-1']?.providerSession).toEqual( + providerSession + ) + }) + it('uses the session file as part of Pi resume ownership only', () => { const base = { paneKey: 'tab-1:leaf-1', diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 42d8fa7a2bf..3b994f8e312 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1651,14 +1651,15 @@ export const createAgentStatusSlice: StateCreator /** Last state from the LEAD session's own events (subagent events carry agent_id, excluded), so a SubagentStop can re-emit pane status; `interrupted` persists so the eventual done still carries it. */ claudeLeadStateByPaneKey: Map + /** Live thread-spawn children per Codex pane. */ + codexSubagentRosterByPaneKey: Map + /** Root Codex state/model, kept separate from child hook traffic. */ + codexLeadStateByPaneKey: Map } export type ClaudeLeadTurnState = { @@ -100,6 +114,11 @@ export type ClaudeLeadTurnState = { stateBeforeWait?: Pick } +type CodexLeadTurnState = { + state: 'working' | 'waiting' | 'done' + model?: string +} + export function createHookListenerState(): HookListenerState { return { warnedVersions: new Set(), @@ -110,7 +129,9 @@ export function createHookListenerState(): HookListenerState { antigravityCompletedTranscriptByPaneKey: new Map(), ampCompletedCacheKeys: new Set(), claudeSubagentRosterByPaneKey: new Map(), - claudeLeadStateByPaneKey: new Map() + claudeLeadStateByPaneKey: new Map(), + codexSubagentRosterByPaneKey: new Map(), + codexLeadStateByPaneKey: new Map() } } @@ -122,6 +143,8 @@ export function clearPaneCacheState(state: HookListenerState, paneKey: string): deletePaneScopedSetEntry(state.ampCompletedCacheKeys, paneKey) state.claudeSubagentRosterByPaneKey.delete(paneKey) state.claudeLeadStateByPaneKey.delete(paneKey) + state.codexSubagentRosterByPaneKey.delete(paneKey) + state.codexLeadStateByPaneKey.delete(paneKey) } function movePaneScopedMapEntries( @@ -163,6 +186,8 @@ export function movePaneCacheState( movePaneScopedSetEntries(state.ampCompletedCacheKeys, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeSubagentRosterByPaneKey, fromPaneKey, toPaneKey) movePaneScopedMapEntries(state.claudeLeadStateByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexSubagentRosterByPaneKey, fromPaneKey, toPaneKey) + movePaneScopedMapEntries(state.codexLeadStateByPaneKey, fromPaneKey, toPaneKey) } function clearPaneTurnCacheState(state: HookListenerState, paneKey: string): void { @@ -202,6 +227,8 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.warnedEnvs.clear() state.claudeSubagentRosterByPaneKey.clear() state.claudeLeadStateByPaneKey.clear() + state.codexSubagentRosterByPaneKey.clear() + state.codexLeadStateByPaneKey.clear() } /** Warn-once on cross-build (`version`) and dev-vs-prod (`env`) mismatches; the relay's "remote" env marker is a location tag, not a build env, so it must not warn as a stale local hook. */ @@ -3000,6 +3027,197 @@ function hasExplicitPromptForSource( return eventName === 'agent.start' && promptText.length > 0 } +function getOrCreateCodexSubagentRoster( + state: HookListenerState, + paneKey: string +): CodexSubagentRoster { + let roster = state.codexSubagentRosterByPaneKey.get(paneKey) + if (!roster) { + roster = new Map() + state.codexSubagentRosterByPaneKey.set(paneKey, roster) + } + return roster +} + +export function seedCodexStateFromSnapshot( + state: HookListenerState, + paneKey: string, + payload: Pick +): void { + const snapshots = payload.subagents ?? [] + if (snapshots.length > 0 && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + seedCodexSubagentRoster(getOrCreateCodexSubagentRoster(state, paneKey), snapshots) + } + if (!state.codexLeadStateByPaneKey.has(paneKey)) { + // Why: child hooks after restart omit the root model; seed it from durable status before they can overwrite the cache. + state.codexLeadStateByPaneKey.set(paneKey, { + // Why: a child wait drives the aggregate waiting state, so it is not evidence that the root itself was waiting. + state: + payload.state === 'done' + ? 'done' + : payload.state === 'waiting' && + !snapshots.some((snapshot) => snapshot.state === 'waiting') + ? 'waiting' + : 'working', + model: payload.model + }) + } +} + +/** Sync the Codex lead record when the server infers an interrupt, so delayed child events cannot restore stale working state. */ +export function markCodexLeadTurnInterrupted(state: HookListenerState, paneKey: string): void { + const lead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { state: 'done', model: lead?.model }) +} + +function codexLeadStateForHookEvent( + eventName: string | undefined +): CodexLeadTurnState['state'] | undefined { + if (eventName === 'Stop') { + return 'done' + } + if (eventName === 'PermissionRequest') { + return 'waiting' + } + if ( + eventName === 'SessionStart' || + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' + ) { + return 'working' + } + return undefined +} + +/** Why: relay restarts lose lead/roster state; merge child events into main's longer-lived cache. */ +export function reconcileRemoteCodexState( + state: HookListenerState, + paneKey: string, + eventName: string | undefined, + agentId: string | undefined, + payload: ParsedAgentStatusPayload, + previous: ParsedAgentStatusPayload | undefined +): ParsedAgentStatusPayload { + if (previous?.agentType === 'codex') { + seedCodexStateFromSnapshot(state, paneKey, previous) + } else { + seedCodexStateFromSnapshot(state, paneKey, payload) + } + + // Why: older relays send child identity without roster snapshots; keep their already-normalized aggregate authoritative. + if (agentId && !payload.subagents && !state.codexSubagentRosterByPaneKey.has(paneKey)) { + return payload + } + const roster = getOrCreateCodexSubagentRoster(state, paneKey) + if (payload.subagents) { + seedCodexSubagentRoster(roster, payload.subagents) + } + if (agentId) { + if (eventName === 'SubagentStop') { + finishCodexSubagent(roster, agentId) + } + } else { + const leadState = codexLeadStateForHookEvent(eventName) + if (eventName === 'SessionStart' || eventName === 'Stop') { + roster.clear() + } + if (leadState) { + const previousLead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { + state: leadState, + model: payload.model ?? previousLead?.model + }) + } + } + + const lead = state.codexLeadStateByPaneKey.get(paneKey) + if (!lead) { + return payload + } + return { + ...payload, + state: codexRosterEffectiveState(roster, lead.state), + model: lead.model ?? payload.model, + subagents: codexRosterToSnapshots(roster) + } +} + +function buildCodexStatusPayload( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record, + options: { stateName: 'working' | 'waiting' | 'done'; updateLead: boolean } +): ParsedAgentStatusPayload | null { + const snapshot = options.updateLead + ? resolveToolState(state, paneKey, extractToolFields('codex', eventName, hookPayload), { + resetOnNewTurn: isNewTurnEvent('codex', eventName) + }) + : (state.lastToolByPaneKey.get(paneKey) ?? {}) + const lead = state.codexLeadStateByPaneKey.get(paneKey) + + return normalizeAgentStatusPayload({ + state: options.stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: options.updateLead && isNewTurnEvent('codex', eventName) + }), + agentType: 'codex', + model: lead?.model, + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + subagents: codexRosterToSnapshots(state.codexSubagentRosterByPaneKey.get(paneKey)) + }) +} + +function buildCodexChildDrivenStatusPayload( + state: HookListenerState, + eventName: unknown, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const leadState = state.codexLeadStateByPaneKey.get(paneKey)?.state ?? 'working' + const stateName = codexRosterEffectiveState( + state.codexSubagentRosterByPaneKey.get(paneKey), + leadState + ) + return buildCodexStatusPayload(state, eventName, '', paneKey, hookPayload, { + stateName, + updateLead: false + }) +} + +function normalizeCodexSubagentLifecycleEvent( + state: HookListenerState, + eventName: 'SubagentStart' | 'SubagentStop', + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const agentId = readString(hookPayload, 'agent_id') + if (!agentId) { + return null + } + const roster = getOrCreateCodexSubagentRoster(state, paneKey) + if (eventName === 'SubagentStart') { + upsertCodexSubagent( + roster, + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: 'working' + }, + Date.now() + ) + } else { + finishCodexSubagent(roster, agentId) + } + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) +} + function normalizeCodexEvent( state: HookListenerState, eventName: unknown, @@ -3007,6 +3225,10 @@ function normalizeCodexEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + if (eventName === 'SubagentStart' || eventName === 'SubagentStop') { + return normalizeCodexSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) + } + const stateName = eventName === 'SessionStart' || eventName === 'UserPromptSubmit' || @@ -3018,31 +3240,47 @@ function normalizeCodexEvent( : eventName === 'Stop' ? 'done' : null - if (!stateName) { return null } - const snapshot = resolveToolState( - state, - paneKey, - extractToolFields('codex', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('codex', eventName) } - ) + const agentId = readString(hookPayload, 'agent_id') + if (agentId) { + upsertCodexSubagent( + getOrCreateCodexSubagentRoster(state, paneKey), + agentId, + { + agentType: readString(hookPayload, 'agent_type'), + model: readString(hookPayload, 'model'), + state: stateName === 'waiting' ? 'waiting' : 'working' + }, + Date.now() + ) + return buildCodexChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + } - return parseAgentStatusPayload( - JSON.stringify({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('codex', eventName) - }), - agentType: 'codex', - toolName: snapshot.toolName, - toolInput: snapshot.toolInput, - interactivePrompt: snapshot.interactivePrompt, - lastAssistantMessage: snapshot.lastAssistantMessage - }) + if (eventName === 'SessionStart') { + // Why: a pane can host a new Codex process after the old one exited without child Stop hooks. + state.codexSubagentRosterByPaneKey.delete(paneKey) + } else if (eventName === 'Stop') { + // Why: Codex CLI 0.144 can omit child Stop hooks; later child activity safely recreates any agent still running. + state.codexSubagentRosterByPaneKey.delete(paneKey) + } + const previousLead = state.codexLeadStateByPaneKey.get(paneKey) + state.codexLeadStateByPaneKey.set(paneKey, { + state: stateName, + model: + normalizeOptionalField(hookPayload['model'], AGENT_MODEL_MAX_LENGTH) ?? + (eventName === 'SessionStart' ? undefined : previousLead?.model) + }) + const effectiveState = codexRosterEffectiveState( + state.codexSubagentRosterByPaneKey.get(paneKey), + stateName ) + return buildCodexStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: effectiveState, + updateLead: true + }) } function normalizeOpenCodeFamilyEvent( @@ -3694,7 +3932,12 @@ export function normalizeHookPayload( } // Why: connectionId is null here; ingestRemote stamps it from mux identity on receive. See docs/design/agent-status-over-ssh.md §5. - const providerSession = extractAgentProviderSession(source, hookPayloadRecord) + // Why: Codex child hooks expose the child's session_id on the parent's pane; + // treating it as the root resume id would replace the terminal's real session. + const providerSession = + source === 'codex' && readString(hookPayloadRecord, 'agent_id') + ? null + : extractAgentProviderSession(source, hookPayloadRecord) const providerSessionOnly = source === 'pi' && eventName === 'session_start' && providerSession !== null // Why: Pi session_start carries resume identity while idle; providerSessionOnly makes receivers discard the placeholder row. diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index d555d9ae913..b413741479e 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -467,6 +467,7 @@ Fix dispatch fallback preview for normalized status prompts` state: 'working', startedAt: 100, agentType: 'general-purpose', + model: undefined, description: undefined }) // Why: non-finite startedAt coerces to 0; descriptions fold to one line. @@ -490,6 +491,7 @@ describe('agentSubagentsEqual', () => { expect(agentSubagentsEqual(undefined, undefined)).toBe(true) expect(agentSubagentsEqual([snapshot], [{ ...snapshot }])).toBe(true) expect(agentSubagentsEqual([snapshot], [{ ...snapshot, state: 'idle' }])).toBe(false) + expect(agentSubagentsEqual([snapshot], [{ ...snapshot, model: 'gpt-5.4-mini' }])).toBe(false) expect(agentSubagentsEqual([snapshot], undefined)).toBe(false) expect(agentSubagentsEqual(undefined, [snapshot])).toBe(false) expect(agentSubagentsEqual([snapshot], [snapshot, { ...snapshot, id: 'b' }])).toBe(false) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 28e8385c2b3..11a6385c764 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -66,14 +66,16 @@ export type AgentStatusOrchestrationContext = { orchestrationRunId?: string } -export type AgentSubagentState = 'working' | 'idle' +export type AgentSubagentState = 'working' | 'blocked' | 'waiting' | 'idle' -/** A live in-process subagent/teammate of the pane's session (Claude Subagent hooks + - * the `background_tasks` field on Stop). Rendered as an indented child row with no PTY of its own. */ +/** A live in-process child of the pane's provider session. Rendered as an + * indented child row with no PTY of its own. */ export type AgentSubagentSnapshot = { - /** Provider-assigned id (Claude hook `agent_id`). */ + /** Provider-assigned lifecycle id. */ id: string agentType?: string + /** Provider model used by this child, when exposed by its lifecycle event. */ + model?: string description?: string state: AgentSubagentState /** Timestamp (ms) when this subagent was first observed. */ @@ -91,6 +93,8 @@ export type AgentStatusEntry = { * Why: separate from updatedAt so tool/prompt pings (which reset updatedAt) don't move it. */ stateStartedAt: number agentType?: AgentType + /** Provider model currently used by this session. */ + model?: string /** Composite key: `${tabId}:${leafId}` where leafId is a stable UUID layout leaf. */ paneKey: string /** Runtime terminal handle for matching retained parent rows when the parent @@ -150,6 +154,7 @@ export type AgentStatusPayload = { state: AgentStatusState prompt?: string agentType?: AgentType + model?: string toolName?: string toolInput?: string /** JSON string of the AskUserQuestion tool input, captured live. See the @@ -157,7 +162,7 @@ export type AgentStatusPayload = { interactivePrompt?: string lastAssistantMessage?: string interrupted?: boolean - /** Live subagents/teammates of the reporting session. See AgentStatusEntry. */ + /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] } @@ -230,6 +235,7 @@ export function isFreshNonDoneAgentStatus( const VALID_STATES: ReadonlySet = new Set(AGENT_STATUS_STATES) /** Maximum character length for the agentType label. Truncated on parse. */ export const AGENT_TYPE_MAX_LENGTH = 40 +export const AGENT_MODEL_MAX_LENGTH = 120 /** Maximum subagent child rows carried per status entry. Bounds per-pane cache * and IPC fanout against a runaway spawner. */ @@ -248,7 +254,12 @@ function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null if (id.length === 0 || id.length > AGENT_SUBAGENT_ID_MAX_LENGTH) { return null } - if (obj.state !== 'working' && obj.state !== 'idle') { + if ( + obj.state !== 'working' && + obj.state !== 'blocked' && + obj.state !== 'waiting' && + obj.state !== 'idle' + ) { return null } return { @@ -257,6 +268,7 @@ function normalizeSubagentSnapshot(value: unknown): AgentSubagentSnapshot | null startedAt: typeof obj.startedAt === 'number' && Number.isFinite(obj.startedAt) ? obj.startedAt : 0, agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), + model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), description: normalizeOptionalField(obj.description, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH) } } @@ -298,6 +310,7 @@ export function agentSubagentsEqual( x.state !== y.state || x.startedAt !== y.startedAt || x.agentType !== y.agentType || + x.model !== y.model || x.description !== y.description ) { return false @@ -330,6 +343,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | prompt: normalizePromptField(obj.prompt), // Why: normalize like the other single-line fields so embedded newlines (e.g. `agentType: "claude\nrogue"`) can't break single-line UI and equality checks. agentType: normalizeOptionalField(obj.agentType, AGENT_TYPE_MAX_LENGTH), + model: normalizeOptionalField(obj.model, AGENT_MODEL_MAX_LENGTH), toolName: normalizeOptionalField(obj.toolName, AGENT_STATUS_TOOL_NAME_MAX_LENGTH), toolInput: normalizeOptionalField(obj.toolInput, AGENT_STATUS_TOOL_INPUT_MAX_LENGTH), interactivePrompt: normalizeInteractivePromptField( diff --git a/src/shared/codex-subagent-roster.test.ts b/src/shared/codex-subagent-roster.test.ts new file mode 100644 index 00000000000..ebb947dedf6 --- /dev/null +++ b/src/shared/codex-subagent-roster.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_MAX_SUBAGENTS, + AGENT_TYPE_MAX_LENGTH +} from './agent-status-types' +import { + codexRosterToSnapshots, + finishCodexSubagent, + upsertCodexSubagent, + type CodexSubagentRoster +} from './codex-subagent-roster' + +describe('Codex subagent roster', () => { + it('normalizes retained identity fields before storing them', () => { + const roster: CodexSubagentRoster = new Map() + + upsertCodexSubagent( + roster, + ' child-1 ', + { + agentType: `reviewer\n${'x'.repeat(AGENT_TYPE_MAX_LENGTH * 2)}`, + model: `gpt-model-${'x'.repeat(AGENT_MODEL_MAX_LENGTH * 2)}`, + state: 'working' + }, + 10 + ) + + const snapshot = codexRosterToSnapshots(roster)?.[0] + expect([...roster.keys()]).toEqual(['child-1']) + expect(snapshot?.agentType).toHaveLength(AGENT_TYPE_MAX_LENGTH) + expect(snapshot?.agentType).not.toContain('\n') + expect(snapshot?.model).toHaveLength(AGENT_MODEL_MAX_LENGTH) + + finishCodexSubagent(roster, ' child-1 ') + expect(roster.size).toBe(0) + }) + + it('rejects an id that would normalize to an invisible child', () => { + const roster: CodexSubagentRoster = new Map() + + upsertCodexSubagent(roster, ' ', { state: 'waiting' }, 10) + + expect(roster.size).toBe(0) + }) + + it('bounds live storage while admitting a replacement after one child stops', () => { + const roster: CodexSubagentRoster = new Map() + for (let index = 0; index <= AGENT_STATUS_MAX_SUBAGENTS; index += 1) { + upsertCodexSubagent(roster, `child-${index}`, { state: 'working' }, index) + } + + expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) + expect(roster.has(`child-${AGENT_STATUS_MAX_SUBAGENTS}`)).toBe(false) + + finishCodexSubagent(roster, 'child-0') + upsertCodexSubagent(roster, 'replacement', { state: 'working' }, 100) + + expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) + expect(roster.has('replacement')).toBe(true) + }) +}) diff --git a/src/shared/codex-subagent-roster.ts b/src/shared/codex-subagent-roster.ts new file mode 100644 index 00000000000..5c4d3e6d7a0 --- /dev/null +++ b/src/shared/codex-subagent-roster.ts @@ -0,0 +1,105 @@ +import { + AGENT_MODEL_MAX_LENGTH, + AGENT_STATUS_MAX_SUBAGENTS, + AGENT_TYPE_MAX_LENGTH, + type AgentSubagentSnapshot +} from './agent-status-types' +import { normalizeOptionalField } from './agent-status-field-normalization' + +const CODEX_SUBAGENT_ID_MAX_LENGTH = 64 + +export type CodexSubagentRoster = Map + +type TrackedCodexSubagent = { + agentType?: string + model?: string + state: 'working' | 'waiting' + startedAt: number +} + +export function upsertCodexSubagent( + roster: CodexSubagentRoster, + id: string, + fields: { + agentType?: string + model?: string + state: 'working' | 'waiting' + }, + now: number +): void { + const normalizedId = id.trim() + if (normalizedId.length === 0 || normalizedId.length > CODEX_SUBAGENT_ID_MAX_LENGTH) { + return + } + const agentType = normalizeOptionalField(fields.agentType, AGENT_TYPE_MAX_LENGTH) + const model = normalizeOptionalField(fields.model, AGENT_MODEL_MAX_LENGTH) + const existing = roster.get(normalizedId) + if (existing) { + existing.agentType = agentType ?? existing.agentType + existing.model = model ?? existing.model + existing.state = fields.state + return + } + if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { + return + } + roster.set(normalizedId, { + agentType, + model, + state: fields.state, + startedAt: now + }) +} + +export function finishCodexSubagent(roster: CodexSubagentRoster, id: string): void { + roster.delete(id.trim()) +} + +export function seedCodexSubagentRoster( + roster: CodexSubagentRoster, + snapshots: readonly AgentSubagentSnapshot[] +): void { + for (const snapshot of snapshots) { + if (snapshot.state !== 'working' && snapshot.state !== 'waiting') { + continue + } + upsertCodexSubagent( + roster, + snapshot.id, + { agentType: snapshot.agentType, model: snapshot.model, state: snapshot.state }, + snapshot.startedAt + ) + } +} + +export function codexRosterToSnapshots( + roster: CodexSubagentRoster | undefined +): AgentSubagentSnapshot[] | undefined { + if (!roster || roster.size === 0) { + return undefined + } + const snapshots = Array.from(roster, ([id, tracked]) => ({ + id, + agentType: tracked.agentType, + model: tracked.model, + state: tracked.state, + startedAt: tracked.startedAt + })) + snapshots.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id)) + return snapshots +} + +export function codexRosterEffectiveState( + roster: CodexSubagentRoster | undefined, + leadState: 'working' | 'waiting' | 'done' +): 'working' | 'waiting' | 'done' { + if (!roster || roster.size === 0) { + return leadState + } + for (const tracked of roster.values()) { + if (tracked.state === 'waiting') { + return 'waiting' + } + } + return leadState === 'done' ? 'working' : leadState +}