diff --git a/docs/design/herdr-tui-reporter.md b/docs/design/herdr-tui-reporter.md new file mode 100644 index 00000000000..488d43ac3a7 --- /dev/null +++ b/docs/design/herdr-tui-reporter.md @@ -0,0 +1,41 @@ +# Herdr TUI Reporter + +## Context + +Herdr can identify Qwen Code and persist its session through the integration +added in herdrdev/herdr#2743, but screen matching can miss localized working and +approval states. Qwen Code already owns the exact TUI state, so the interactive +client should report it directly when Herdr launches the pane. + +## Design + +The interactive UI creates one fail-open reporter only when Herdr's pane, +socket, and binary environment variables are present. It reports session IDs +through the existing `herdr:qwen` source and TUI lifecycle through the +`qwen-code:tui` source. This requires Herdr to treat those exact sources as one +Qwen owner while keeping their sequence numbers and release behavior +independent. That paired ownership contract is tracked in +[herdrdev/herdr#2757](https://github.com/herdrdev/herdr/discussions/2757). + +The reported state is `blocked` for authentication or an active tool, +integration, or skill confirmation, `working` while the model, tools, or a +slash command are running, and `idle` otherwise. Reports are serialized and +deduplicated. A newer pending state replaces one that has not started. Command +failures and timeouts never affect the UI. + +Graceful exit drains the active report and releases only `qwen-code:tui`. +Herdr then falls back to screen detection while retaining the official session +identity. Process detection remains the crash fallback. + +## Boundaries + +This is TUI status integration, not an orchestration API. It does not change +core, ACP, headless mode, Agent Team, or how Qwen delegates to Codex, Pi, and +other CLIs through Herdr. + +## Verification + +Focused tests cover environment gating, report ordering and deduplication, +session changes, monotonic sequences, failures, and release. A live Herdr pane +must exercise idle, working, approval-blocked, session switch, and graceful +exit states. diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 7cc73d7dd0f..ef7722c0e44 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -245,6 +245,7 @@ import { useKeypress, type Key } from './hooks/useKeypress.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; import { clearCiEnv } from '../test-utils/ci-env.js'; import { restorePromptStash } from '../services/prompt-stash.js'; +import type { HerdrReporter } from '../utils/herdr-reporter.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -439,7 +440,7 @@ describe('AppContainer State Management', () => { vimMode: 'NORMAL', }); mockedUseSessionStats.mockReturnValue({ - stats: {}, + stats: { sessionId: 'session-1' }, seedPromptCount: vi.fn(), }); mockedUseTextBuffer.mockReturnValue({ @@ -545,6 +546,41 @@ describe('AppContainer State Management', () => { vi.useRealTimers(); }); + it('reports blocked TUI state to Herdr', async () => { + const report = vi.fn(); + const herdrReporter = { report } as unknown as HerdrReporter; + mockedUseFolderTrust.mockReturnValue({ + isFolderTrustDialogOpen: true, + handleFolderTrustSelect: vi.fn(), + isRestarting: false, + }); + mockedUseGeminiStream.mockReturnValue({ + streamingState: StreamingState.Idle, + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + clearPendingState: mockClearPendingState, + }); + + render( + , + ); + await vi.waitFor(() => + expect(report).toHaveBeenLastCalledWith('session-1', 'blocked'), + ); + }); + const rewindUserItem = ( id: number, text: string, @@ -5512,7 +5548,8 @@ describe('AppContainer State Management', () => { }); describe('Keyboard Input Handling', () => { - it('should block quit command during authentication', () => { + it('should block quit command during authentication', async () => { + const report = vi.fn(); mockedUseAuthCommand.mockReturnValue({ authState: 'unauthenticated', setAuthState: vi.fn(), @@ -5569,10 +5606,14 @@ describe('AppContainer State Management', () => { settings={mockSettings} version="1.0.0" initializationResult={mockInitResult} + herdrReporter={{ report } as unknown as HerdrReporter} />, ); expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/quit'); + await vi.waitFor(() => + expect(report).toHaveBeenLastCalledWith('session-1', 'blocked'), + ); }); it('should prevent exit command when text buffer has content', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a77bebbcde7..233ea8799b7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -236,6 +236,10 @@ import { getLiveAgentPanelLayoutKey } from './components/background-view/liveAge import { t } from '../i18n/index.js'; import { TUI_CHAT_RECORDING_FAILURE_MESSAGE } from '../nonInteractive/chat-recording-failure.js'; import { buildPermissionSuggestions } from '../nonInteractive/permission-suggestions.js'; +import type { + HerdrAgentState, + HerdrReporter, +} from '../utils/herdr-reporter.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; @@ -705,6 +709,7 @@ interface AppContainerProps { * stays write-free (static remount bump only), matching pre-PR behavior. */ repaintViewport?: () => void; + herdrReporter?: HerdrReporter | null; } /** @@ -726,6 +731,7 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult, initialUseVirtualViewport, repaintViewport, + herdrReporter, } = props; const extensionRefreshState = useMemo( () => props.extensionRefreshState ?? new ExtensionRefreshState(), @@ -3563,6 +3569,33 @@ export const AppContainer = (props: AppContainerProps) => { history: historyManager.history, sessionStats, }); + const herdrBlocked = + streamingState === StreamingState.WaitingForConfirmation || + !!isFolderTrustDialogOpen || + isMcpApprovalDialogOpen || + !!shellConfirmationRequest || + !!confirmationRequest || + confirmUpdateExtensionRequests.length > 0 || + !!providerUpdateRequest || + settingInputRequests.length > 0 || + pluginChoiceRequests.length > 0 || + !!loopDetectionConfirmationRequest || + isAuthDialogOpen || + isAuthenticating || + (isSkillReviewDialogOpen && !!skillReviewPending) || + showWelcomeBackDialog || + shouldShowIdePrompt || + shouldShowCommandMigrationNudge || + showIdeRestartPrompt; + const herdrState: HerdrAgentState = herdrBlocked + ? 'blocked' + : streamingState === StreamingState.Responding || isProcessing + ? 'working' + : 'idle'; + useEffect(() => { + herdrReporter?.report(sessionStats.sessionId, herdrState); + }, [herdrReporter, herdrState, sessionStats.sessionId]); + const dialogsVisible = showWelcomeBackDialog || shouldShowIdePrompt || diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 2831e1ba79a..9f83bcfbde2 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -58,6 +58,7 @@ import { sanitizeTerminalText } from './utils/textUtils.js'; import { startPostRenderPrefetches } from '../startup/startup-prefetch.js'; import { computeWindowTitle, writeTerminalTitle } from './utils/windowTitle.js'; import { getCliVersion } from '../utils/version.js'; +import { createHerdrReporter } from '../utils/herdr-reporter.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -103,6 +104,11 @@ export async function startInteractiveUI( // ignored: best-effort, never block UI startup. } + const herdrReporter = createHerdrReporter(); + if (herdrReporter) { + registerCleanup(() => herdrReporter.release()); + } + const restoreTerminalRedrawOptimizer = process.stdout.isTTY && !config.getScreenReader() ? installTerminalRedrawOptimizer(process.stdout) @@ -238,6 +244,7 @@ export async function startInteractiveUI( initialUseVirtualViewport={useVP} extensionRefreshState={options.extensionRefreshState} repaintViewport={resizeReflow.repaint} + herdrReporter={herdrReporter} /> diff --git a/packages/cli/src/utils/herdr-reporter.test.ts b/packages/cli/src/utils/herdr-reporter.test.ts new file mode 100644 index 00000000000..25d4d01ed54 --- /dev/null +++ b/packages/cli/src/utils/herdr-reporter.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createHerdrReporter, HerdrReporter } from './herdr-reporter.js'; + +describe('HerdrReporter', () => { + it('is disabled outside a Herdr pane', () => { + expect(createHerdrReporter({})).toBeNull(); + expect( + createHerdrReporter({ + HERDR_ENV: '1', + HERDR_PANE_ID: 'w1:p1', + HERDR_BIN_PATH: '/bin/herdr', + }), + ).toBeNull(); + }); + + it('serializes session, latest state, session changes, and release', async () => { + const calls: string[][] = []; + let resumeFirst: (() => void) | undefined; + const first = new Promise((resolve) => { + resumeFirst = resolve; + }); + const run = vi.fn(async (args: readonly string[]) => { + calls.push([...args]); + if (calls.length === 1) await first; + }); + const reporter = new HerdrReporter('w1:p1', run); + + reporter.report('session-1', 'idle'); + reporter.report('session-1', 'working'); + await vi.waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0]).toContain('report-agent-session'); + expect(calls[0]).not.toContain('--seq'); + + resumeFirst?.(); + await vi.waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1]).toContain('working'); + reporter.report('session-1', 'working'); + await Promise.resolve(); + expect(calls).toHaveLength(2); + + reporter.report('session-2', 'working'); + await vi.waitFor(() => expect(calls).toHaveLength(3)); + expect(calls[2]).toContain('session-2'); + expect(calls[2]).toContain('clear'); + + await reporter.release(); + expect(calls).toHaveLength(4); + expect(calls[3]).toContain('release-agent'); + reporter.report('session-2', 'blocked'); + await Promise.resolve(); + expect(calls).toHaveLength(4); + + const stateSeq = BigInt(calls[1]![calls[1]!.indexOf('--seq') + 1]!); + const releaseSeq = BigInt(calls[3]![calls[3]!.indexOf('--seq') + 1]!); + expect(releaseSeq).toBeGreaterThan(stateSeq); + }); + + it('swallows transport failures', async () => { + const reporter = new HerdrReporter('w1:p1', async () => { + throw new Error('offline'); + }); + + reporter.report('session-1', 'working'); + await expect(reporter.release()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/herdr-reporter.ts b/packages/cli/src/utils/herdr-reporter.ts new file mode 100644 index 00000000000..3d887507e73 --- /dev/null +++ b/packages/cli/src/utils/herdr-reporter.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; + +const AGENT = 'qwen'; +const LIFECYCLE_SOURCE = 'qwen-code:tui'; +const SESSION_SOURCE = 'herdr:qwen'; +const COMMAND_TIMEOUT_MS = 500; + +export type HerdrAgentState = 'blocked' | 'idle' | 'working'; + +type Report = { + sessionId: string; + state: HerdrAgentState; +}; + +type RunCommand = (args: readonly string[]) => Promise; + +export class HerdrReporter { + private pending: Report | undefined; + private drainPromise: Promise | undefined; + private releasePromise: Promise | undefined; + private lastSessionId: string | undefined; + private lastState: HerdrAgentState | undefined; + private sequence = Date.now() * 1000; + private closed = false; + + constructor( + private readonly paneId: string, + private readonly runCommand: RunCommand, + ) {} + + report(sessionId: string, state: HerdrAgentState): void { + if ( + this.closed || + !sessionId || + (this.pending?.sessionId === sessionId && this.pending.state === state) || + (!this.pending && + this.lastSessionId === sessionId && + this.lastState === state) + ) { + return; + } + + this.pending = { sessionId, state }; + this.startDrain(); + } + + release(): Promise { + if (!this.releasePromise) { + this.closed = true; + this.pending = undefined; + this.releasePromise = (async () => { + await this.drainPromise; + await this.run([ + 'pane', + 'release-agent', + this.paneId, + '--source', + LIFECYCLE_SOURCE, + '--agent', + AGENT, + '--seq', + this.nextSequence(), + ]); + })(); + } + return this.releasePromise; + } + + private startDrain(): void { + if (this.drainPromise) return; + this.drainPromise = this.drain().finally(() => { + this.drainPromise = undefined; + if (this.pending && !this.closed) this.startDrain(); + }); + } + + private async drain(): Promise { + while (this.pending && !this.closed) { + const report = this.pending; + this.pending = undefined; + + if (report.sessionId !== this.lastSessionId) { + if ( + !(await this.run([ + 'pane', + 'report-agent-session', + this.paneId, + '--source', + SESSION_SOURCE, + '--agent', + AGENT, + '--agent-session-id', + report.sessionId, + '--session-start-source', + this.lastSessionId ? 'clear' : 'startup', + ])) + ) { + continue; + } + this.lastSessionId = report.sessionId; + } + + if (this.pending || this.closed) continue; + if (report.state !== this.lastState) { + if ( + await this.run([ + 'pane', + 'report-agent', + this.paneId, + '--source', + LIFECYCLE_SOURCE, + '--agent', + AGENT, + '--state', + report.state, + '--seq', + this.nextSequence(), + ]) + ) { + this.lastState = report.state; + } + } + } + } + + private nextSequence(): string { + return String(++this.sequence); + } + + private async run(args: readonly string[]): Promise { + try { + await this.runCommand(args); + return true; + } catch { + // ignored: Herdr reporting must never affect the TUI. + return false; + } + } +} + +export function createHerdrReporter( + env: NodeJS.ProcessEnv = process.env, + runCommand?: RunCommand, +): HerdrReporter | null { + const paneId = env['HERDR_PANE_ID']; + const binary = env['HERDR_BIN_PATH']; + if ( + env['HERDR_ENV'] !== '1' || + !paneId || + !binary || + !env['HERDR_SOCKET_PATH'] + ) { + return null; + } + + return new HerdrReporter( + paneId, + runCommand ?? ((args) => spawnCommand(binary, args)), + ); +} + +function spawnCommand(binary: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + try { + const child = spawn(binary, args, { + stdio: 'ignore', + timeout: COMMAND_TIMEOUT_MS, + windowsHide: true, + }); + child.once('error', reject); + child.once('close', (code) => + code === 0 ? resolve() : reject(new Error(`Herdr exited ${code}`)), + ); + } catch (error) { + reject(error); + } + }); +}