diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts
index 51c696886..80c1b0a90 100644
--- a/packages/cli/src/ui/commands/arenaCommand.ts
+++ b/packages/cli/src/ui/commands/arenaCommand.ts
@@ -334,7 +334,7 @@ function executeArenaCommand(
})
.then(
() => {
- debugLogger.debug('Arena session completed');
+ debugLogger.debug('Arena agents settled');
},
(error) => {
const message = error instanceof Error ? error.message : String(error);
@@ -344,13 +344,18 @@ function executeArenaCommand(
// Clear the stored manager so subsequent /arena start calls
// are not blocked by the stale reference after a startup failure.
config.setArenaManager(null);
+
+ // Detach listeners on failure — session is done for good.
+ for (const detach of detachListeners) {
+ detach();
+ }
},
- )
- .finally(() => {
- for (const detach of detachListeners) {
- detach();
- }
- });
+ );
+
+ // NOTE: listeners are NOT detached when start() resolves because agents
+ // may still be alive (IDLE) and accept follow-up tasks. The listeners
+ // reference this manager's emitter, so they are garbage collected when
+ // the manager is cleaned up and replaced.
// Store so that stop can wait for start() to fully unwind before cleanup
manager.setLifecyclePromise(lifecycle);
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 193549245..78eefabc3 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -104,8 +104,8 @@ export const Composer = () => {
{/* Exclusive area: only one component visible at a time */}
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
- {!showSuggestions &&
- uiState.streamingState !== StreamingState.WaitingForConfirmation &&
+ {uiState.isInputActive &&
+ !showSuggestions &&
(showShortcuts ? (
) : (
diff --git a/packages/cli/src/ui/components/arena/ArenaCards.tsx b/packages/cli/src/ui/components/arena/ArenaCards.tsx
index fe6db8075..1ad7d8e2a 100644
--- a/packages/cli/src/ui/components/arena/ArenaCards.tsx
+++ b/packages/cli/src/ui/components/arena/ArenaCards.tsx
@@ -148,11 +148,13 @@ export const ArenaSessionCard: React.FC = ({
const colChanges = 10;
const titleLabel =
- sessionStatus === 'completed'
- ? 'Arena Complete'
- : sessionStatus === 'cancelled'
- ? 'Arena Cancelled'
- : 'Arena Failed';
+ sessionStatus === 'idle'
+ ? 'Agents Status · Idle'
+ : sessionStatus === 'completed'
+ ? 'Arena Complete'
+ : sessionStatus === 'cancelled'
+ ? 'Arena Cancelled'
+ : 'Arena Failed';
return (
= ({
{/* Hint */}
+ {sessionStatus === 'idle' && (
+
+
+ Switch to an agent tab to continue, or{' '}
+ /arena select to pick a
+ winner.
+
+
+ )}
{sessionStatus === 'completed' && (
diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
index 0786cbac0..1a126c102 100644
--- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
+++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
@@ -12,7 +12,7 @@ import {
type ArenaAgentState,
type InProcessBackend,
type AgentStatsSummary,
- isTerminalStatus,
+ isSettledStatus,
ArenaSessionStatus,
DISPLAY_MODE,
} from '@qwen-code/qwen-code-core';
@@ -46,7 +46,7 @@ function pad(
}
function getElapsedMs(agent: ArenaAgentState): number {
- if (isTerminalStatus(agent.status)) {
+ if (isSettledStatus(agent.status)) {
return agent.stats.durationMs;
}
return Date.now() - agent.startedAt;
@@ -61,6 +61,8 @@ function getSessionStatusLabel(status: ArenaSessionStatus): {
return { text: 'Running', color: theme.status.success };
case ArenaSessionStatus.INITIALIZING:
return { text: 'Initializing', color: theme.status.warning };
+ case ArenaSessionStatus.IDLE:
+ return { text: 'Idle', color: theme.status.success };
case ArenaSessionStatus.COMPLETED:
return { text: 'Completed', color: theme.status.success };
case ArenaSessionStatus.CANCELLED:
diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts
index 7cb29d312..0f7db9220 100644
--- a/packages/cli/src/ui/hooks/useArenaInProcess.ts
+++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts
@@ -18,9 +18,11 @@
import { useEffect, useRef } from 'react';
import {
ArenaEventType,
+ ArenaSessionStatus,
DISPLAY_MODE,
type ArenaManager,
type ArenaAgentStartEvent,
+ type ArenaSessionCompleteEvent,
type Config,
type InProcessBackend,
} from '@qwen-code/qwen-code-core';
@@ -123,9 +125,9 @@ export function useArenaInProcess(config: Config): void {
tryRegister(MAX_AGENT_RETRIES);
};
- // On session end, unregister agents, remove listeners from this
- // manager, and resume polling for a genuinely new manager instance.
- const onSessionEnd = () => {
+ // Tear down agent tabs, remove listeners, and resume polling for
+ // a genuinely new manager instance.
+ const teardown = () => {
actionsRef.current.unregisterAll();
for (const timeout of retryTimeouts) {
clearTimeout(timeout);
@@ -133,8 +135,8 @@ export function useArenaInProcess(config: Config): void {
retryTimeouts.clear();
// Remove listeners eagerly so they don't fire again
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
- emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd);
- emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd);
+ emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
+ emitter.off(ArenaEventType.SESSION_ERROR, teardown);
detachListeners = null;
// Keep attachedManager reference — prevents reattach to this
// same (completed) manager on the next poll tick.
@@ -144,14 +146,24 @@ export function useArenaInProcess(config: Config): void {
}
};
+ // When agents settle to IDLE the session is still alive — keep
+ // the tab bar so users can continue interacting with agents.
+ // Only tear down on truly terminal session statuses.
+ const onSessionComplete = (event: ArenaSessionCompleteEvent) => {
+ if (event.result.status === ArenaSessionStatus.IDLE) {
+ return;
+ }
+ teardown();
+ };
+
emitter.on(ArenaEventType.AGENT_START, onAgentStart);
- emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionEnd);
- emitter.on(ArenaEventType.SESSION_ERROR, onSessionEnd);
+ emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
+ emitter.on(ArenaEventType.SESSION_ERROR, teardown);
detachListeners = () => {
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
- emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd);
- emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd);
+ emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
+ emitter.off(ArenaEventType.SESSION_ERROR, teardown);
};
};
diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
index 5faa39a2f..5cfdc782f 100644
--- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
+++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
@@ -67,8 +67,8 @@ export const DefaultAppLayout: React.FC = () => {
- {/* Tab bar: visible whenever in-process agents exist */}
- {hasAgents && }
+ {/* Tab bar: visible whenever in-process agents exist and input is active */}
+ {hasAgents && !uiState.dialogsVisible && }
);
};
diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts
index 7f422e250..4f8fabb16 100644
--- a/packages/cli/src/ui/utils/displayUtils.ts
+++ b/packages/cli/src/ui/utils/displayUtils.ts
@@ -17,6 +17,8 @@ export interface StatusLabel {
export function getArenaStatusLabel(status: AgentStatus): StatusLabel {
switch (status) {
+ case AgentStatus.IDLE:
+ return { icon: '✓', text: 'Idle', color: theme.status.success };
case AgentStatus.COMPLETED:
return { icon: '✓', text: 'Done', color: theme.status.success };
case AgentStatus.CANCELLED:
diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts
index 172ef632f..b17341fc5 100644
--- a/packages/core/src/agents/arena/ArenaManager.ts
+++ b/packages/core/src/agents/arena/ArenaManager.ts
@@ -36,7 +36,11 @@ import {
ARENA_MAX_AGENTS,
safeAgentId,
} from './types.js';
-import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
+import {
+ AgentStatus,
+ isTerminalStatus,
+ isSettledStatus,
+} from '../runtime/agent-types.js';
import {
logArenaSessionStarted,
logArenaAgentCompleted,
@@ -374,9 +378,10 @@ export class ArenaManager {
this.sessionStatus = ArenaSessionStatus.RUNNING;
await this.runAgents();
- // Only mark as completed if not already cancelled/timed out
+ // Mark session as idle (agents finished but still alive) unless
+ // already cancelled/timed out.
if (this.sessionStatus === ArenaSessionStatus.RUNNING) {
- this.sessionStatus = ArenaSessionStatus.COMPLETED;
+ this.sessionStatus = ArenaSessionStatus.IDLE;
}
// Collect results (uses this.sessionStatus for result status)
@@ -1114,6 +1119,25 @@ export class ArenaManager {
timestamp: Date.now(),
});
+ // Emit progress messages for follow-up transitions (only after
+ // the initial task — the session is IDLE once all agents first settle).
+ if (this.sessionStatus === ArenaSessionStatus.IDLE) {
+ const displayName = agent.model.displayName || agent.model.modelId;
+ if (
+ previousStatus === AgentStatus.IDLE &&
+ newStatus === AgentStatus.RUNNING
+ ) {
+ this.emitProgress(
+ `Agent ${displayName} is working on a follow-up task…`,
+ );
+ } else if (
+ previousStatus === AgentStatus.RUNNING &&
+ newStatus === AgentStatus.IDLE
+ ) {
+ this.emitProgress(`Agent ${displayName} finished follow-up task.`);
+ }
+ }
+
// Emit AGENT_COMPLETE when agent reaches a terminal status
if (isTerminalStatus(newStatus)) {
const result = this.buildAgentResult(agent);
@@ -1194,7 +1218,7 @@ export class ArenaManager {
return new Promise((resolve) => {
const checkSettled = () => {
for (const agent of this.agents.values()) {
- if (!isTerminalStatus(agent.status)) {
+ if (!isSettledStatus(agent.status)) {
return false;
}
}
@@ -1283,7 +1307,7 @@ export class ArenaManager {
agent.error =
interactive.getLastRoundError() || interactive.getError();
}
- if (isTerminalStatus(resolved)) {
+ if (isSettledStatus(resolved)) {
agent.stats.durationMs = Date.now() - agent.startedAt;
}
this.updateAgentStatus(agent.agentId, resolved);
@@ -1337,9 +1361,9 @@ export class ArenaManager {
const consolidatedAgents: Record = {};
for (const agent of this.agents.values()) {
- // Only poll agents that are still alive (RUNNING)
+ // Only poll agents that are actively working
if (
- isTerminalStatus(agent.status) ||
+ isSettledStatus(agent.status) ||
agent.status === AgentStatus.INITIALIZING
) {
continue;
diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts
index b99059cbd..aaf3e2dae 100644
--- a/packages/core/src/agents/arena/types.ts
+++ b/packages/core/src/agents/arena/types.ts
@@ -21,7 +21,9 @@ export enum ArenaSessionStatus {
INITIALIZING = 'initializing',
/** Session is running */
RUNNING = 'running',
- /** Session completed (all agents finished) */
+ /** All agents finished their current task and are idle (can accept follow-ups) */
+ IDLE = 'idle',
+ /** Session completed for good (winner selected or explicit end) */
COMPLETED = 'completed',
/** Session was cancelled */
CANCELLED = 'cancelled',
diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts
index 24b898bb4..5109c91bd 100644
--- a/packages/core/src/agents/backends/InProcessBackend.ts
+++ b/packages/core/src/agents/backends/InProcessBackend.ts
@@ -20,7 +20,7 @@ import {
createContentGenerator,
} from '../../core/contentGenerator.js';
import { AUTH_ENV_MAPPINGS } from '../../models/constants.js';
-import { AgentStatus } from '../runtime/agent-types.js';
+import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
import { AgentCore } from '../runtime/agent-core.js';
import { AgentEventEmitter } from '../runtime/agent-events.js';
import { ContextState } from '../runtime/agent-headless.js';
@@ -130,9 +130,14 @@ export class InProcessBackend implements Backend {
const context = new ContextState();
await interactive.start(context);
- // Watch for completion and fire exit callback
+ // Watch for completion and fire exit callback — but only for
+ // truly terminal statuses. IDLE means the agent is still alive
+ // and can accept follow-up messages.
void interactive.waitForCompletion().then(() => {
const status = interactive.getStatus();
+ if (!isTerminalStatus(status)) {
+ return;
+ }
const exitCode =
status === AgentStatus.COMPLETED
? 0
diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts
index 9c3162d22..f0ac9fb88 100644
--- a/packages/core/src/agents/runtime/agent-interactive.test.ts
+++ b/packages/core/src/agents/runtime/agent-interactive.test.ts
@@ -114,7 +114,7 @@ describe('AgentInteractive', () => {
await agent.start(context);
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
expect(core.runReasoningLoop).toHaveBeenCalledOnce();
@@ -123,6 +123,7 @@ describe('AgentInteractive', () => {
expect(agent.getMessages()[0]?.content).toBe('Do something');
await agent.shutdown();
+ expect(agent.getStatus()).toBe('completed');
});
it('should process enqueued messages', async () => {
@@ -134,7 +135,7 @@ describe('AgentInteractive', () => {
agent.enqueueMessage('Hello');
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
expect(core.runReasoningLoop).toHaveBeenCalledOnce();
@@ -197,7 +198,7 @@ describe('AgentInteractive', () => {
// Second message works fine
agent.enqueueMessage('recover');
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
expect(callCount).toBe(2);
});
@@ -313,7 +314,7 @@ describe('AgentInteractive', () => {
await agent.start(context);
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
const assistantMsgs = agent
@@ -352,12 +353,12 @@ describe('AgentInteractive', () => {
await agent.start(context);
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
agent.enqueueMessage('second message');
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
expect(runCount).toBe(2);
});
@@ -399,7 +400,7 @@ describe('AgentInteractive', () => {
await agent.start(context);
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
const messages = agent.getMessages();
@@ -458,7 +459,7 @@ describe('AgentInteractive', () => {
await agent.start(context);
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
const messages = agent.getMessages();
@@ -517,7 +518,7 @@ describe('AgentInteractive', () => {
await agent.start(context);
await vi.waitFor(() => {
- expect(agent.getStatus()).toBe('completed');
+ expect(agent.getStatus()).toBe('idle');
});
const messages = agent.getMessages();
diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts
index 4970077e0..7e35a96db 100644
--- a/packages/core/src/agents/runtime/agent-interactive.ts
+++ b/packages/core/src/agents/runtime/agent-interactive.ts
@@ -323,12 +323,16 @@ export class AgentInteractive {
// ─── Private Helpers ───────────────────────────────────────
- /** Emit terminal status for the just-completed round. */
+ /**
+ * Settle status after the run loop empties.
+ * On success → IDLE (agent stays alive for follow-up messages).
+ * On error → FAILED (terminal).
+ */
private settleRoundStatus(): void {
if (this.lastRoundError) {
this.setStatus(AgentStatus.FAILED);
} else {
- this.setStatus(AgentStatus.COMPLETED);
+ this.setStatus(AgentStatus.IDLE);
}
}
diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts
index 2684406c1..ca7e283f6 100644
--- a/packages/core/src/agents/runtime/agent-types.ts
+++ b/packages/core/src/agents/runtime/agent-types.ts
@@ -99,28 +99,34 @@ export enum AgentTerminateMode {
* Canonical lifecycle status for any agent (headless, interactive, arena).
*
* State machine:
- * INITIALIZING → RUNNING ⇄ COMPLETED / FAILED / CANCELLED
+ * INITIALIZING → RUNNING → IDLE ⇄ RUNNING → … → COMPLETED / FAILED / CANCELLED
*
* - INITIALIZING: Setting up (creating chat, loading tools).
* - RUNNING: Actively processing (model thinking / tool execution).
- * - COMPLETED: Finished successfully (may re-enter RUNNING on new input).
+ * - IDLE: Finished current work, waiting — can accept new messages.
+ * - COMPLETED: Finished for good (explicit shutdown). No further interaction.
* - FAILED: Finished with error (API failure, process crash, etc.).
* - CANCELLED: Cancelled by user or system.
*/
export enum AgentStatus {
INITIALIZING = 'initializing',
RUNNING = 'running',
+ IDLE = 'idle',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
}
-/** True for COMPLETED, FAILED, CANCELLED — agent is done working. */
+/** True for COMPLETED, FAILED, CANCELLED — agent is done for good. */
export const isTerminalStatus = (s: AgentStatus): boolean =>
s === AgentStatus.COMPLETED ||
s === AgentStatus.FAILED ||
s === AgentStatus.CANCELLED;
+/** True for terminal statuses OR IDLE — agent has settled (not actively working). */
+export const isSettledStatus = (s: AgentStatus): boolean =>
+ s === AgentStatus.IDLE || isTerminalStatus(s);
+
/**
* Lightweight configuration for an AgentInteractive instance.
* Carries only interactive-specific parameters; the heavy runtime