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);
+ }
+ });
+}