This document explains how the Roo Code CLI detects and tracks the agent loop state.
The CLI needs to know when the agent is:
This is accomplished by analyzing the messages the extension sends to the client.
All agent activity is communicated through ClineMessages - a stream of timestamped messages that represent everything the agent does.
interface ClineMessage {
ts: number // Unique timestamp identifier
type: "ask" | "say" // Message category
ask?: ClineAsk // Specific ask type (when type="ask")
say?: ClineSay // Specific say type (when type="say")
text?: string // Message content
partial?: boolean // Is this message still streaming?
}
| Type | Purpose | Blocks Agent? |
|---|---|---|
| say | Informational - agent is telling you something | No |
| ask | Interactive - agent needs something from you | Usually yes |
The agent loop stops whenever the last message is an
asktype (withpartial: false).
The specific ask value tells you exactly what the agent needs.
The CLI categorizes asks into four groups:
WAITING_FOR_INPUT stateThese require user action to continue:
| Ask Type | What It Means | Required Response |
|---|---|---|
tool |
Wants to edit/create/delete files | Approve or Reject |
command |
Wants to run a terminal command | Approve or Reject |
followup |
Asking a question | Text answer |
browser_action_launch |
Wants to use the browser | Approve or Reject |
use_mcp_server |
Wants to use an MCP server | Approve or Reject |
IDLE stateThese indicate the task has stopped:
| Ask Type | What It Means | Response Options |
|---|---|---|
completion_result |
Task completed successfully | New task or feedback |
api_req_failed |
API request failed | Retry or new task |
mistake_limit_reached |
Too many errors | Continue anyway or new task |
auto_approval_max_req_reached |
Auto-approval limit hit | Continue manually or stop |
resume_completed_task |
Viewing completed task | New task |
RESUMABLE state| Ask Type | What It Means | Response Options |
|---|---|---|
resume_task |
Task paused mid-execution | Resume or abandon |
RUNNING state| Ask Type | What It Means | Response Options |
|---|---|---|
command_output |
Command is running | Continue or abort |
The agent is streaming when:
partial: true on the last message, ORAn api_req_started message exists with cost: undefined in its text field
// Streaming detection pseudocode
function isStreaming(messages) {
const lastMessage = messages.at(-1)
// Check partial flag (primary indicator)
if (lastMessage?.partial === true) {
return true
}
// Check for in-progress API request
const apiReq = messages.findLast((m) => m.say === "api_req_started")
if (apiReq?.text) {
const data = JSON.parse(apiReq.text)
if (data.cost === undefined) {
return true // API request not yet complete
}
}
return false
}
┌─────────────────┐
│ NO_TASK │ (no messages)
└────────┬────────┘
│ newTask
▼
┌─────────────────────────────┐
┌───▶│ RUNNING │◀───┐
│ └──────────┬──────────────────┘ │
│ │ │
│ ┌──────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────┐ ┌─────────┐ ┌──────────┐ │
│ │STREAM│ │WAITING_ │ │ IDLE │ │
│ │ ING │ │FOR_INPUT│ │ │ │
│ └──┬───┘ └────┬────┘ └────┬─────┘ │
│ │ │ │ │
│ │ done │ approved │ newTask │
└────┴───────────┴────────────┘ │
│
┌──────────────┐ │
│ RESUMABLE │────────────────────────┘
└──────────────┘ resumed
┌─────────────────────────────────────────────────────────────────┐
│ ExtensionHost │
│ │
│ ┌──────────────────┐ │
│ │ Extension │──── extensionWebviewMessage ─────┐ │
│ │ (Task.ts) │ │ │
│ └──────────────────┘ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ ExtensionClient │ │
│ │ (Single Source of Truth) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌────────────────────┐ │ │
│ │ │ MessageProcessor │───▶│ StateStore │ │ │
│ │ │ │ │ (clineMessages) │ │ │
│ │ └─────────────────┘ └────────┬───────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ detectAgentState() │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Events: stateChange, message, waitingForInput, etc. │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ OutputManager │ │ AskDispatcher │ │ PromptManager │ │
│ │ (stdout) │ │ (ask routing) │ │ (user input) │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
The single source of truth for agent state, including the current mode. It:
StateStoredetectAgentState()Emits events when state changes (including mode changes)
const client = new ExtensionClient({
sendMessage: (msg) => extensionHost.sendToExtension(msg),
debug: true, // Writes to ~/.roo/cli-debug.log
})
// Query state at any time
const state = client.getAgentState()
if (state.isWaitingForInput) {
console.log(`Agent needs: ${state.currentAsk}`)
}
// Query current mode
const mode = client.getCurrentMode()
console.log(`Current mode: ${mode}`) // e.g., "code", "architect", "ask"
// Subscribe to events
client.on("waitingForInput", (event) => {
console.log(`Waiting for: ${event.ask}`)
})
// Subscribe to mode changes
client.on("modeChanged", (event) => {
console.log(`Mode changed: ${event.previousMode} -> ${event.currentMode}`)
})
Holds the clineMessages array, computed state, and current mode:
interface StoreState {
messages: ClineMessage[] // The raw message array
agentState: AgentStateInfo // Computed state
isInitialized: boolean // Have we received any state?
currentMode: string | undefined // Current mode (e.g., "code", "architect")
}
Handles incoming messages from the extension:
"state" messages → Update clineMessages array and track mode"messageUpdated" messages → Update single message in arrayRoutes asks to appropriate handlers:
isIdleAsk(), isInteractiveAsk(), etc.OutputManager and PromptManager-y flag), auto-approves everythingHandles all CLI output:
process.stdout (bypasses quiet mode)Handles user input:
When the agent is waiting, send these responses:
// Approve an action (tool, command, browser, MCP)
client.sendMessage({
type: "askResponse",
askResponse: "yesButtonClicked",
})
// Reject an action
client.sendMessage({
type: "askResponse",
askResponse: "noButtonClicked",
})
// Answer a question
client.sendMessage({
type: "askResponse",
askResponse: "messageResponse",
text: "My answer here",
})
// Start a new task
client.sendMessage({
type: "newTask",
text: "Build a web app",
})
// Cancel current task
client.sendMessage({
type: "cancelTask",
})
The CLI uses type guards from @roo-code/types for categorization:
import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "@roo-code/types"
const ask = message.ask
if (isInteractiveAsk(ask)) {
// Needs approval: tool, command, followup, etc.
} else if (isIdleAsk(ask)) {
// Task stopped: completion_result, api_req_failed, etc.
} else if (isResumableAsk(ask)) {
// Task paused: resume_task
} else if (isNonBlockingAsk(ask)) {
// Command running: command_output
}
Enable with -d flag. Logs go to ~/.roo/cli-debug.log:
roo -d -y -P "Build something" --no-tui
View logs:
tail -f ~/.roo/cli-debug.log
Example output:
[MessageProcessor] State update: {
"messageCount": 5,
"lastMessage": {
"msgType": "ask:completion_result"
},
"stateTransition": "running → idle",
"currentAsk": "completion_result",
"isWaitingForInput": true
}
[MessageProcessor] EMIT waitingForInput: { "ask": "completion_result" }
[MessageProcessor] EMIT taskCompleted: { "success": true }
ClineMessage streamask messages (non-partial) block the agentpartial: true or api_req_started without cost = streamingExtensionClient is the single source of truth