فهرست منبع

Merge branch 'main' into fireworks-pull-1

Kevin van Dijk 1 هفته پیش
والد
کامیت
f1dd72c116
100فایلهای تغییر یافته به همراه10714 افزوده شده و 363 حذف شده
  1. 0 5
      .changeset/abbreviate-large-pasted-text.md
  2. 5 0
      .changeset/add-slovak-translation.md
  3. 0 5
      .changeset/checkpoint-enable-disable.md
  4. 0 5
      .changeset/cli-custom-modes-error-message.md
  5. 0 5
      .changeset/cli-max-concurrent-file-reads.md
  6. 0 5
      .changeset/cli-models-api-command.md
  7. 0 5
      .changeset/cli-nano-gpt-router-models.md
  8. 0 7
      .changeset/colorblind-theme-support.md
  9. 1 1
      .changeset/config.json
  10. 0 5
      .changeset/delete-file-cli-display.md
  11. 5 0
      .changeset/fix-agent-manager-double-scrollbar.md
  12. 0 5
      .changeset/fix-approval-number-hotkeys.md
  13. 0 5
      .changeset/fix-chat-autocomplete-focus.md
  14. 0 5
      .changeset/fix-cli-rate-limit-hang.md
  15. 16 0
      .changeset/fix-session-title-readability.md
  16. 0 6
      .changeset/full-eagles-brush.md
  17. 0 88
      .changeset/good-tools-accept.md
  18. 0 5
      .changeset/markdown-autocomplete-filter.md
  19. 5 0
      .changeset/remove-dup-title.md
  20. 5 0
      .changeset/spotty-turtles-retire.md
  21. 0 5
      .changeset/wild-lobsters-shake.md
  22. 1 1
      .devcontainer/Dockerfile
  23. 3 3
      .github/actions/ai-release-notes/action.yml
  24. 5 0
      .github/dependabot.yml
  25. 5 5
      .github/workflows/build-cli.yml
  26. 3 3
      .github/workflows/changeset-release.yml
  27. 18 18
      .github/workflows/cli-publish.yml
  28. 21 21
      .github/workflows/code-qa.yml
  29. 2 2
      .github/workflows/evals.yml
  30. 5 5
      .github/workflows/markdoc-build.yml
  31. 13 13
      .github/workflows/marketplace-publish.yml
  32. 5 5
      .github/workflows/storybook-playwright-snapshot.yml
  33. 3 3
      .github/workflows/update-contributors.yml
  34. 6 0
      .gitignore
  35. 1 1
      .kilocode/skills/translation/SKILL.md
  36. 1 1
      .kilocode/workflows/add-missing-translations.md
  37. 58 38
      .kilocodemodes
  38. 1 1
      .nvmrc
  39. 1 1
      .tool-versions
  40. 4 4
      .vscode/launch.json
  41. 1 56
      .vscode/tasks.json
  42. 93 2
      AGENTS.md
  43. 459 0
      CHANGELOG.md
  44. 135 11
      CONTRIBUTING.md
  45. 1 1
      DEVELOPMENT.md
  46. 22 6
      README.md
  47. 116 0
      apps/cli/CHANGELOG.md
  48. 262 0
      apps/cli/README.md
  49. 355 0
      apps/cli/docs/AGENT_LOOP.md
  50. 4 0
      apps/cli/eslint.config.mjs
  51. 305 0
      apps/cli/install.sh
  52. 48 0
      apps/cli/package.json
  53. 714 0
      apps/cli/scripts/release.sh
  54. 126 0
      apps/cli/src/__tests__/index.test.ts
  55. 858 0
      apps/cli/src/agent/__tests__/extension-client.test.ts
  56. 596 0
      apps/cli/src/agent/__tests__/extension-host.test.ts
  57. 466 0
      apps/cli/src/agent/agent-state.ts
  58. 681 0
      apps/cli/src/agent/ask-dispatcher.ts
  59. 372 0
      apps/cli/src/agent/events.ts
  60. 580 0
      apps/cli/src/agent/extension-client.ts
  61. 542 0
      apps/cli/src/agent/extension-host.ts
  62. 1 0
      apps/cli/src/agent/index.ts
  63. 479 0
      apps/cli/src/agent/message-processor.ts
  64. 414 0
      apps/cli/src/agent/output-manager.ts
  65. 297 0
      apps/cli/src/agent/prompt-manager.ts
  66. 415 0
      apps/cli/src/agent/state-store.ts
  67. 3 0
      apps/cli/src/commands/auth/index.ts
  68. 186 0
      apps/cli/src/commands/auth/login.ts
  69. 27 0
      apps/cli/src/commands/auth/logout.ts
  70. 97 0
      apps/cli/src/commands/auth/status.ts
  71. 1 0
      apps/cli/src/commands/cli/index.ts
  72. 219 0
      apps/cli/src/commands/cli/run.ts
  73. 2 0
      apps/cli/src/commands/index.ts
  74. 65 0
      apps/cli/src/index.ts
  75. 1 0
      apps/cli/src/lib/auth/index.ts
  76. 61 0
      apps/cli/src/lib/auth/token.ts
  77. 30 0
      apps/cli/src/lib/sdk/client.ts
  78. 2 0
      apps/cli/src/lib/sdk/index.ts
  79. 31 0
      apps/cli/src/lib/sdk/types.ts
  80. 152 0
      apps/cli/src/lib/storage/__tests__/credentials.test.ts
  81. 240 0
      apps/cli/src/lib/storage/__tests__/history.test.ts
  82. 22 0
      apps/cli/src/lib/storage/config-dir.ts
  83. 72 0
      apps/cli/src/lib/storage/credentials.ts
  84. 10 0
      apps/cli/src/lib/storage/ephemeral.ts
  85. 109 0
      apps/cli/src/lib/storage/history.ts
  86. 4 0
      apps/cli/src/lib/storage/index.ts
  87. 40 0
      apps/cli/src/lib/storage/settings.ts
  88. 102 0
      apps/cli/src/lib/utils/__tests__/commands.test.ts
  89. 54 0
      apps/cli/src/lib/utils/__tests__/extension.test.ts
  90. 128 0
      apps/cli/src/lib/utils/__tests__/input.test.ts
  91. 68 0
      apps/cli/src/lib/utils/__tests__/path.test.ts
  92. 34 0
      apps/cli/src/lib/utils/__tests__/provider.test.ts
  93. 62 0
      apps/cli/src/lib/utils/commands.ts
  94. 67 0
      apps/cli/src/lib/utils/context-window.ts
  95. 33 0
      apps/cli/src/lib/utils/extension.ts
  96. 122 0
      apps/cli/src/lib/utils/input.ts
  97. 33 0
      apps/cli/src/lib/utils/onboarding.ts
  98. 35 0
      apps/cli/src/lib/utils/path.ts
  99. 61 0
      apps/cli/src/lib/utils/provider.ts
  100. 6 0
      apps/cli/src/lib/utils/version.ts

+ 0 - 5
.changeset/abbreviate-large-pasted-text.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": minor
----
-
-Abbreviate large pasted text in CLI input as `[Pasted text #N +X lines]` to prevent input field overflow when pasting logs or large code blocks

+ 5 - 0
.changeset/add-slovak-translation.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Add Slovak (sk) language translation for Kilo Code extension and UI

+ 0 - 5
.changeset/checkpoint-enable-disable.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Add `/checkpoint enable` and `/checkpoint disable` subcommands to toggle checkpoint creation and save disk space

+ 0 - 5
.changeset/cli-custom-modes-error-message.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-fix(cli): improve error message for custom mode not found

+ 0 - 5
.changeset/cli-max-concurrent-file-reads.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Add maxConcurrentFileReads configuration support to CLI with documentation

+ 0 - 5
.changeset/cli-models-api-command.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": minor
----
-
-Add `kilocode models --json` command to expose available models as JSON for programmatic use

+ 0 - 5
.changeset/cli-nano-gpt-router-models.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Fix CLI `/model list` returning "No models available" for nano-gpt provider

+ 0 - 7
.changeset/colorblind-theme-support.md

@@ -1,7 +0,0 @@
----
-"@kilocode/cli": minor
----
-
-Add colorblind theme support to CLI
-
-- Colorblind-friendly theme with high contrast colors for accessibility

+ 1 - 1
.changeset/config.json

@@ -7,5 +7,5 @@
 	"access": "restricted",
 	"baseBranch": "main",
 	"updateInternalDependencies": "patch",
-	"ignore": []
+	"ignore": ["@roo-code/cli"]
 }

+ 0 - 5
.changeset/delete-file-cli-display.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Add proper display for deleteFile tool in CLI instead of showing "Unknown tool: deleteFile"

+ 5 - 0
.changeset/fix-agent-manager-double-scrollbar.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+fix(agent-manager): Fix double scrollbar in mode selector dropdowns

+ 0 - 5
.changeset/fix-approval-number-hotkeys.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Fix number key hotkeys (1, 2, 3) not working in command approval menu

+ 0 - 5
.changeset/fix-chat-autocomplete-focus.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Fix chat autocomplete to only show suggestions when textarea has focus, text hasn't changed, and clear suggestions on paste

+ 0 - 5
.changeset/fix-cli-rate-limit-hang.md

@@ -1,5 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Fix CLI hanging on rate limit errors in autonomous mode by enabling auto-retry for API failures

+ 16 - 0
.changeset/fix-session-title-readability.md

@@ -0,0 +1,16 @@
+---
+"webview-ui": patch
+---
+
+Fix unreadable text and poor contrast issues in Agent Manager
+
+**Session list item (issue #5618):**
+
+- Change selected session item background from list-activeSelectionBackground to button-background for better contrast
+- Change selected session item text color from list-activeSelectionForeground to button-foreground
+
+**Session detail view:**
+
+- Change session header, messages container, and chat input backgrounds from editor-background to sideBar-background
+- Add explicit text color to session title using titleBar-activeForeground
+- Add explicit color to messages container using sideBar-foreground

+ 0 - 6
.changeset/full-eagles-brush.md

@@ -1,6 +0,0 @@
----
-"@kilocode/cli": patch
-"kilo-code": patch
----
-
-fix: use correct api url for some endpoints

+ 0 - 88
.changeset/good-tools-accept.md

@@ -1,88 +0,0 @@
----
-"kilo-code": minor
----
-
-Include changes from Roo Code v3.36.7-v3.38.3
-
-- Feat: Add option in Context settings to recursively load `.kilocode/rules` and `AGENTS.md` from subdirectories (PR #10446 by @mrubens)
-- Fix: Stop frequent Claude Code sign-ins by hardening OAuth refresh token handling (PR #10410 by @hannesrudolph)
-- Fix: Add `maxConcurrentFileReads` limit to native `read_file` tool schema (PR #10449 by @app/roomote)
-- Fix: Add type check for `lastMessage.text` in TTS useEffect to prevent runtime errors (PR #10431 by @app/roomote)
-- Align skills system with Agent Skills specification (PR #10409 by @hannesrudolph)
-- Prevent write_to_file from creating files at truncated paths (PR #10415 by @mrubens and @daniel-lxs)
-- Fix rate limit wait display (PR #10389 by @hannesrudolph)
-- Remove human-relay provider (PR #10388 by @hannesrudolph)
-- Fix: Flush pending tool results before condensing context (PR #10379 by @daniel-lxs)
-- Fix: Revert mergeToolResultText for OpenAI-compatible providers (PR #10381 by @hannesrudolph)
-- Fix: Enforce maxConcurrentFileReads limit in read_file tool (PR #10363 by @roomote)
-- Fix: Improve feedback message when read_file is used on a directory (PR #10371 by @roomote)
-- Fix: Handle custom tool use similarly to MCP tools for IPC schema purposes (PR #10364 by @jr)
-- Add support for npm packages and .env files to custom tools, allowing custom tools to import dependencies and access environment variables (PR #10336 by @cte)
-- Remove simpleReadFileTool feature, streamlining the file reading experience (PR #10254 by @app/roomote)
-- Remove OpenRouter Transforms feature (PR #10341 by @app/roomote)
-- Fix: Send native tool definitions by default for OpenAI to ensure proper tool usage (PR #10314 by @hannesrudolph)
-- Fix: Preserve reasoning_details shape to prevent malformed responses when processing model output (PR #10313 by @hannesrudolph)
-- Fix: Drain queued messages while waiting for ask to prevent message loss (PR #10315 by @hannesrudolph)
-- Feat: Add grace retry for empty assistant messages to improve reliability (PR #10297 by @hannesrudolph)
-- Feat: Enable mergeToolResultText for all OpenAI-compatible providers for better tool result handling (PR #10299 by @hannesrudolph)
-- Feat: Strengthen native tool-use guidance in prompts for improved model behavior (PR #10311 by @hannesrudolph)
-- Add MiniMax M2.1 and improve environment_details handling for Minimax thinking models (PR #10284 by @hannesrudolph)
-- Add GLM-4.7 model with thinking mode support for Zai provider (PR #10282 by @hannesrudolph)
-- Add experimental custom tool calling - define custom tools that integrate seamlessly with your AI workflow (PR #10083 by @cte)
-- Deprecate XML tool protocol selection and force native tool format for new tasks (PR #10281 by @daniel-lxs)
-- Fix: Emit tool_call_end events in OpenAI handler when streaming ends (#10275 by @torxeon, PR #10280 by @daniel-lxs)
-- Fix: Emit tool_call_end events in BaseOpenAiCompatibleProvider (PR #10293 by @hannesrudolph)
-- Fix: Disable strict mode for MCP tools to preserve optional parameters (PR #10220 by @daniel-lxs)
-- Fix: Move array-specific properties into anyOf variant in normalizeToolSchema (PR #10276 by @daniel-lxs)
-- Fix: Add graceful fallback for model parsing in Chutes provider (PR #10279 by @hannesrudolph)
-- Fix: Enable Requesty refresh models with credentials (PR #10273 by @daniel-lxs)
-- Fix: Improve reasoning_details accumulation and serialization (PR #10285 by @hannesrudolph)
-- Fix: Preserve reasoning_content in condense summary for DeepSeek-reasoner (PR #10292 by @hannesrudolph)
-- Refactor Zai provider to merge environment_details into tool result instead of system message (PR #10289 by @hannesrudolph)
-- Remove parallel_tool_calls parameter from litellm provider (PR #10274 by @roomote)
-- Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph)
-- Add 1M context window beta support for Claude Sonnet 4 on Vertex AI, enabling significantly larger context for complex tasks (PR #10209 by @hannesrudolph)
-- Add native tool call defaults for OpenAI-compatible providers, expanding native function calling across more configurations (PR #10213 by @hannesrudolph)
-- Enable native tool calls for Requesty provider (PR #10211 by @daniel-lxs)
-- Improve API error handling and visibility with clearer error messages and better user feedback (PR #10204 by @brunobergher)
-- Add downloadable error diagnostics from chat errors, making it easier to troubleshoot and report issues (PR #10188 by @brunobergher)
-- Fix refresh models button not properly flushing the cache, ensuring model lists update correctly (#9682 by @tl-hbk, PR #9870 by @pdecat)
-- Fix additionalProperties handling for strict mode compatibility, resolving schema validation issues with certain providers (PR #10210 by @daniel-lxs)
-- Add native tool calling support for Claude models on Vertex AI, enabling more efficient and reliable tool interactions (PR #10197 by @hannesrudolph)
-- Fix JSON Schema format value stripping for OpenAI compatibility, resolving issues with unsupported format values (PR #10198 by @daniel-lxs)
-- Improve "no tools used" error handling with graceful retry mechanism for better reliability when tools fail to execute (PR #10196 by @hannesrudolph)
-- Change default tool protocol from XML to native for improved reliability and performance (PR #10186 by @mrubens)
-- Add native tool support for VS Code Language Model API providers (PR #10191 by @daniel-lxs)
-- Lock task tool protocol for consistent task resumption, ensuring tasks resume with the same protocol they started with (PR #10192 by @daniel-lxs)
-- Replace edit_file tool alias with actual edit_file tool for improved diff editing capabilities (PR #9983 by @hannesrudolph)
-- Fix LiteLLM router models by merging default model info for native tool calling support (PR #10187 by @daniel-lxs)
-- Fix: Add userAgentAppId to Bedrock embedder for code indexing (#10165 by @jackrein, PR #10166 by @roomote)
-- Update OpenAI and Gemini tool preferences for improved model behavior (PR #10170 by @hannesrudolph)
-- Add support for Claude Code Provider native tool calling, improving tool execution performance and reliability (PR #10077 by @hannesrudolph)
-- Enable native tool calling by default for Z.ai models for better model compatibility (PR #10158 by @app/roomote)
-- Enable native tools by default for OpenAI compatible provider to improve tool calling support (PR #10159 by @daniel-lxs)
-- Fix: Normalize MCP tool schemas for Bedrock and OpenAI strict mode to ensure proper tool compatibility (PR #10148 by @daniel-lxs)
-- Fix: Remove dots and colons from MCP tool names for Bedrock compatibility (PR #10152 by @daniel-lxs)
-- Fix: Convert tool_result to XML text when native tools disabled for Bedrock (PR #10155 by @daniel-lxs)
-- Fix: Support AWS GovCloud and China region ARNs in Bedrock provider for expanded regional support (PR #10157 by @app/roomote)
-- Implement interleaved thinking mode for DeepSeek Reasoner, enabling streaming reasoning output (PR #9969 by @hannesrudolph)
-- Fix: Preserve reasoning_content during tool call sequences in DeepSeek (PR #10141 by @hannesrudolph)
-- Fix: Correct token counting for context truncation display (PR #9961 by @hannesrudolph)
-- Fix: Normalize tool call IDs for cross-provider compatibility via OpenRouter, ensuring consistent handling across different AI providers (PR #10102 by @daniel-lxs)
-- Fix: Add additionalProperties: false to nested MCP tool schemas, improving schema validation and preventing unexpected properties (PR #10109 by @daniel-lxs)
-- Fix: Validate tool_result IDs in delegation resume flow, preventing errors when resuming delegated tasks (PR #10135 by @daniel-lxs)
-- Feat: Add full error details to streaming failure dialog, providing more comprehensive information for debugging streaming issues (PR #10131 by @roomote)
-- Implement incremental token-budgeted file reading for smarter, more efficient file content retrieval (PR #10052 by @jr)
-- Enable native tools by default for multiple providers including OpenAI, Azure, Google, Vertex, and more (PR #10059 by @daniel-lxs)
-- Enable native tools by default for Anthropic and add telemetry tracking for tool format usage (PR #10021 by @daniel-lxs)
-- Fix: Prevent race condition from deleting wrong API messages during streaming (PR #10113 by @hannesrudolph)
-- Fix: Prevent duplicate MCP tools error by deduplicating servers at source (PR #10096 by @daniel-lxs)
-- Remove strict ARN validation for Bedrock custom ARN users allowing more flexibility (#10108 by @wisestmumbler, PR #10110 by @roomote)
-- Add metadata to error details dialog for improved debugging (PR #10050 by @roomote)
-- Remove description from Bedrock service tiers for cleaner UI (PR #10118 by @mrubens)
-- Improve tool configuration for OpenAI models in OpenRouter (PR #10082 by @hannesrudolph)
-- Capture more detailed provider-specific error information from OpenRouter for better debugging (PR #10073 by @jr)
-- Add Amazon Nova 2 Lite model to Bedrock provider (#9802 by @Smartsheet-JB-Brown, PR #9830 by @roomote)
-- Add AWS Bedrock service tier support (#9874 by @Smartsheet-JB-Brown, PR #9955 by @roomote)
-- Remove auto-approve toggles for to-do and retry actions to simplify the approval workflow (PR #10062 by @hannesrudolph)
-- Move isToolAllowedForMode out of shared directory for better code organization (PR #10089 by @cte)

+ 0 - 5
.changeset/markdown-autocomplete-filter.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Minor improvement to markdown autocomplete suggestions

+ 5 - 0
.changeset/remove-dup-title.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Remove duplicate "Kilo Code Marketplace" title in toolbar (thanks @bernaferrari!)

+ 5 - 0
.changeset/spotty-turtles-retire.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Hook embedding timeout into settings for ollama

+ 0 - 5
.changeset/wild-lobsters-shake.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-fix: configure husky hooks for reliable execution

+ 1 - 1
.devcontainer/Dockerfile

@@ -2,7 +2,7 @@
 # Based on flake.nix dependencies for standardized development environment
 
 # Use official Node.js image matching .nvmrc version
-FROM node:20.19.2-bullseye
+FROM node:20.20.0-bullseye
 
 # Install system dependencies (matching flake.nix packages)
 RUN apt-get update && apt-get install -y \

+ 3 - 3
.github/actions/ai-release-notes/action.yml

@@ -20,9 +20,9 @@ inputs:
         #   runs-on: ubuntu-latest
         #   steps:
         #     - name: Checkout code
-        #       uses: actions/checkout@v4
+        #       uses: actions/checkout@v6
         #     - name: Setup Node.js
-        #       uses: actions/setup-node@v4
+        #       uses: actions/setup-node@v6
         #       with:
         #         node-version: '18'
         #         cache: 'npm'
@@ -58,7 +58,7 @@ env:
 runs:
     using: "composite"
     steps:
-        - uses: actions/checkout@v4
+        - uses: actions/checkout@v6
           with:
               repository: ${{ inputs.repo_path }}
               token: ${{ inputs.GHA_PAT }}

+ 5 - 0
.github/dependabot.yml

@@ -14,3 +14,8 @@ updates:
     directory: "/cli"
     schedule:
       interval: "weekly"
+      
+  - package-ecosystem: "github-actions"
+    directory: "/"
+    schedule:
+      interval: "weekly"

+ 5 - 5
.github/workflows/build-cli.yml

@@ -7,7 +7,7 @@ on:
     workflow_dispatch:
 env:
     GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
@@ -16,18 +16,18 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
             - name: Install pnpm
               uses: pnpm/action-setup@v4
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -45,7 +45,7 @@ jobs:
               run: npm pack
               working-directory: cli/dist
             - name: Upload artifact
-              uses: actions/upload-artifact@v4
+              uses: actions/upload-artifact@v6
               with:
                   name: tarball
                   path: cli/dist/*.tgz

+ 3 - 3
.github/workflows/changeset-release.yml

@@ -9,7 +9,7 @@ on:
 env:
     REPO_PATH: ${{ github.repository }}
     GIT_REF: main
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
 
 jobs:
@@ -21,7 +21,7 @@ jobs:
             pull-requests: write
         steps:
             - name: Git Checkout
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   fetch-depth: 0
                   ref: ${{ env.GIT_REF }}
@@ -32,7 +32,7 @@ jobs:
                   version: ${{ env.PNPM_VERSION }}
 
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"

+ 18 - 18
.github/workflows/cli-publish.yml

@@ -8,7 +8,7 @@ env:
     GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     DOCKER_IMAGE_NAME: kiloai/cli
     DOCKER_PLATFORMS: linux/amd64,linux/arm64
@@ -27,11 +27,11 @@ jobs:
             publish: ${{ steps.check.outputs.publish }}
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
             - name: Check Published Version
@@ -53,7 +53,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Install pnpm
@@ -61,12 +61,12 @@ jobs:
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -91,7 +91,7 @@ jobs:
               working-directory: cli/dist
               run: npm pack
             - name: Upload Packaged CLI
-              uses: actions/upload-artifact@v4
+              uses: actions/upload-artifact@v6
               with:
                   name: cli-packaged
                   path: cli/dist/*.tgz
@@ -106,17 +106,17 @@ jobs:
             contents: read
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
             - name: Update npm
               run: npm install -g npm@latest
             - name: Download Build Artifact
-              uses: actions/download-artifact@v4
+              uses: actions/download-artifact@v7
               with:
                   name: cli-packaged
                   path: cli/dist
@@ -131,7 +131,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Set up Docker Buildx
@@ -142,12 +142,12 @@ jobs:
                   username: ${{ secrets.DOCKER_USERNAME }}
                   password: ${{ secrets.DOCKER_PASSWORD }}
             - name: Download Packaged CLI
-              uses: actions/download-artifact@v4
+              uses: actions/download-artifact@v7
               with:
                   name: cli-packaged
                   path: cli/dist
             - name: Build and push Debian image
-              uses: docker/build-push-action@v5
+              uses: docker/build-push-action@v6
               with:
                   context: ./cli
                   file: ./cli/Dockerfile
@@ -171,7 +171,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Set up Docker Buildx
@@ -182,12 +182,12 @@ jobs:
                   username: ${{ secrets.DOCKER_USERNAME }}
                   password: ${{ secrets.DOCKER_PASSWORD }}
             - name: Download Packaged CLI
-              uses: actions/download-artifact@v4
+              uses: actions/download-artifact@v7
               with:
                   name: cli-packaged
                   path: cli/dist
             - name: Build and push Alpine image
-              uses: docker/build-push-action@v5
+              uses: docker/build-push-action@v6
               with:
                   context: ./cli
                   file: ./cli/Dockerfile
@@ -211,11 +211,11 @@ jobs:
             contents: write
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Download Build Artifact
-              uses: actions/download-artifact@v4
+              uses: actions/download-artifact@v7
               with:
                   name: cli-packaged
                   path: cli/dist

+ 21 - 21
.github/workflows/code-qa.yml

@@ -9,7 +9,7 @@ on:
         branches: [main]
 
 env:
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
@@ -19,18 +19,18 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
             - name: Install pnpm
               uses: pnpm/action-setup@v4
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -49,18 +49,18 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
             - name: Install pnpm
               uses: pnpm/action-setup@v4
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: "18"
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -78,18 +78,18 @@ jobs:
                 os: [ubuntu-latest, windows-latest]
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
             - name: Install pnpm
               uses: pnpm/action-setup@v4
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -120,18 +120,18 @@ jobs:
                 os: [ubuntu-latest, windows-latest]
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
             - name: Install pnpm
               uses: pnpm/action-setup@v4
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: "18"
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -154,7 +154,7 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   submodules: recursive
                   lfs: true
@@ -163,11 +163,11 @@ jobs:
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
-            - uses: actions/setup-java@v4
+            - uses: actions/setup-java@v5
               with:
                   distribution: "jetbrains"
                   java-version: "21"
@@ -175,7 +175,7 @@ jobs:
               env:
                   GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
             - name: Setup Gradle
-              uses: gradle/actions/setup-gradle@v4
+              uses: gradle/actions/setup-gradle@v5
               with:
                   cache-read-only: ${{ github.ref != 'refs/heads/main' }}
             - name: Install system dependencies
@@ -188,7 +188,7 @@ jobs:
                     build-essential \
                     python3
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -204,18 +204,18 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
             - name: Install pnpm
               uses: pnpm/action-setup@v4
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}

+ 2 - 2
.github/workflows/evals.yml

@@ -22,7 +22,7 @@ jobs:
 
         steps:
             - name: Checkout repository
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
 
             - name: Set up Docker Buildx
               uses: docker/setup-buildx-action@v3
@@ -41,7 +41,7 @@ jobs:
                   EOF
 
             - name: Build image
-              uses: docker/build-push-action@v5
+              uses: docker/build-push-action@v6
               with:
                   context: .
                   file: packages/evals/Dockerfile.runner

+ 5 - 5
.github/workflows/docusaurus-build.yml → .github/workflows/markdoc-build.yml

@@ -1,7 +1,7 @@
-name: Docusaurus Build Check
+name: Markdoc Build Check
 
 env:
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
 
 on:
@@ -15,11 +15,11 @@ permissions:
 
 jobs:
     build:
-        name: Build Docusaurus Site
+        name: Build Markdoc Site
         runs-on: ubuntu-latest
 
         steps:
-            - uses: actions/checkout@v4
+            - uses: actions/checkout@v6
 
             - name: Install pnpm
               uses: pnpm/action-setup@v4
@@ -27,7 +27,7 @@ jobs:
                   version: ${{ env.PNPM_VERSION }}
 
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"

+ 13 - 13
.github/workflows/marketplace-publish.yml

@@ -6,7 +6,7 @@ on:
 
 env:
     GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
@@ -25,7 +25,7 @@ jobs:
             version: ${{ steps.get-version.outputs.version }}
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Install pnpm
@@ -33,7 +33,7 @@ jobs:
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
@@ -114,7 +114,7 @@ jobs:
                     bin/kilo-code-${current_package_version}.vsix
                   echo "Successfully created GitHub Release v${current_package_version}"
             - name: Upload VSIX artifact
-              uses: actions/upload-artifact@v4
+              uses: actions/upload-artifact@v6
               with:
                   name: kilo-code-vsix
                   path: bin/kilo-code-${{ steps.get-version.outputs.version }}.vsix
@@ -129,7 +129,7 @@ jobs:
             github.event_name == 'workflow_dispatch'
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   ref: ${{ env.GIT_REF }}
             - name: Install pnpm
@@ -137,14 +137,14 @@ jobs:
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
             - name: Install dependencies
               run: pnpm install
             - name: Download VSIX artifact
-              uses: actions/download-artifact@v4
+              uses: actions/download-artifact@v7
               with:
                   name: kilo-code-vsix
                   path: bin/
@@ -170,7 +170,7 @@ jobs:
             github.event_name == 'workflow_dispatch'
         steps:
             - name: Checkout code
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   submodules: recursive
                   lfs: true
@@ -179,18 +179,18 @@ jobs:
               with:
                   version: ${{ env.PNPM_VERSION }}
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
-            - uses: actions/setup-java@v4
+            - uses: actions/setup-java@v5
               with:
                   distribution: "jetbrains"
                   java-version: "21"
                   check-latest: false
                   token: ${{ secrets.GITHUB_TOKEN }}
             - name: Setup Gradle
-              uses: gradle/actions/setup-gradle@v4
+              uses: gradle/actions/setup-gradle@v5
               with:
                   cache-read-only: false
             - name: Install system dependencies
@@ -203,7 +203,7 @@ jobs:
                     build-essential \
                     python3
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -229,7 +229,7 @@ jobs:
                     --clobber
                   echo "Successfully attached JetBrains plugin to GitHub Release v${current_package_version}"
             - name: Upload artifact
-              uses: actions/upload-artifact@v4
+              uses: actions/upload-artifact@v6
               with:
                   name: ${{ env.BUNDLE_NAME }}
                   path: jetbrains/plugin/build/distributions/${{ env.BUNDLE_NAME }}

+ 5 - 5
.github/workflows/storybook-playwright-snapshot.yml

@@ -21,7 +21,7 @@ concurrency:
 env:
     DOCKER_BUILDKIT: 1
     COMPOSE_DOCKER_CLI_BUILD: 1
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     SECRETS_SET: ${{ secrets.OPENROUTER_API_KEY != '' }}
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
@@ -35,7 +35,7 @@ jobs:
 
         steps:
             - name: Checkout repository
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   # Chromatic needs full git history for baseline comparison
                   fetch-depth: 0
@@ -46,13 +46,13 @@ jobs:
                   version: ${{ env.PNPM_VERSION }}
 
             - name: Set up Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: ${{ env.NODE_VERSION }}
                   cache: "pnpm"
 
             - name: Turbo cache setup
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: .turbo
                   key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -71,7 +71,7 @@ jobs:
               uses: docker/setup-buildx-action@v3
 
             - name: Cache Docker layers
-              uses: actions/cache@v4
+              uses: actions/cache@v5
               with:
                   path: /tmp/.buildx-cache
                   key: ${{ runner.os }}-buildx-${{ hashFiles('apps/playwright-e2e/Dockerfile.playwright-ci') }}

+ 3 - 3
.github/workflows/update-contributors.yml

@@ -10,17 +10,17 @@ jobs:
         runs-on: ubuntu-latest
         steps:
             - name: Checkout repository
-              uses: actions/checkout@v4
+              uses: actions/checkout@v6
               with:
                   token: ${{ secrets.GITHUB_TOKEN }}
 
             - name: Setup pnpm
-              uses: pnpm/action-setup@v2
+              uses: pnpm/action-setup@v4
               with:
                   version: latest
 
             - name: Setup Node.js
-              uses: actions/setup-node@v4
+              uses: actions/setup-node@v6
               with:
                   node-version: 22
                   cache: "pnpm"

+ 6 - 0
.gitignore

@@ -53,6 +53,10 @@ logs
 # Nix / Direnv
 .direnv
 
+# Mise
+mise.toml
+mise.local.toml
+
 # Exclude Conport Directory (MCP server)
 context_portal/
 
@@ -71,3 +75,5 @@ qdrant_storage/
 .secrets
 # Architect plans
 ./plans/
+
+roo-cli-*.tar.gz*

+ 1 - 1
.kilocode/skills/translation/SKILL.md

@@ -13,7 +13,7 @@ For the translation workflow, use the `/add-missing-translations` command or see
 
 # 1. SUPPORTED LANGUAGES AND LOCATION
 
-- Localize all strings into the following locale files: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW
+- Localize all strings into the following locale files: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, sk, th, tr, uk, vi, zh-CN, zh-TW
 - The VSCode extension has two main areas that require localization:
     - Core Extension: src/i18n/locales/, src/package.nls.json, src/package.nls.<locale>.json (extension backend)
     - WebView UI: webview-ui/src/i18n/locales/ (user interface)

+ 1 - 1
.kilocode/workflows/add-missing-translations.md

@@ -13,7 +13,7 @@ For each language that is missing translations:
 
 When translating, follow these key rules:
 
-1. **Supported Languages**: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW
+1. **Supported Languages**: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, sk, th, tr, uk, vi, zh-CN, zh-TW
 2. **Voice**: Always use informal speech (e.g., "du" not "Sie" in German)
 3. **Technical Terms**: Don't translate "token", "API", "prompt" and domain-specific technical terms
 4. **Placeholders**: Keep `{{variable}}` placeholders exactly as in the English source

+ 58 - 38
.kilocodemodes

@@ -1,38 +1,58 @@
-{
-	"customModes": [
-		{
-			"slug": "translate",
-			"name": "Translate",
-			"roleDefinition": "You are Kilo Code, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.",
-			"groups": [
-				"read",
-				[
-					"edit",
-					{
-						"fileRegex": "((src/i18n/locales/)|(src/package\\.nls(\\.\\w+)?\\.json))",
-						"description": "Translation files only"
-					}
-				]
-			],
-			"customInstructions": "When translating content:\n- Maintain consistent terminology across all translations\n- Respect the JSON structure of translation files\n- Consider context when translating UI strings\n- Watch for placeholders (like {{variable}}) and preserve them in translations\n- Be mindful of text length in UI elements when translating to languages that might require more characters\n- If you need context for a translation, use read_file to examine the components using these strings\n- Specifically \"Kilo\", \"Kilo Code\" and similar terms are project names and proper nouns and must remain unchanged in translations"
-		},
-		{
-			"slug": "test",
-			"name": "Test",
-			"roleDefinition": "You are Kilo Code, a Jest testing specialist with deep expertise in:\n- Writing and maintaining Jest test suites\n- Test-driven development (TDD) practices\n- Mocking and stubbing with Jest\n- Integration testing strategies\n- TypeScript testing patterns\n- Code coverage analysis\n- Test performance optimization\n\nYour focus is on maintaining high test quality and coverage across the codebase, working primarily with:\n- Test files in __tests__ directories\n- Mock implementations in __mocks__\n- Test utilities and helpers\n- Jest configuration and setup\n\nYou ensure tests are:\n- Well-structured and maintainable\n- Following Jest best practices\n- Properly typed with TypeScript\n- Providing meaningful coverage\n- Using appropriate mocking strategies",
-			"groups": [
-				"read",
-				"browser",
-				"command",
-				[
-					"edit",
-					{
-						"fileRegex": "(__tests__/.*|__mocks__/.*|\\.test\\.(ts|tsx|js|jsx)$|/test/.*|jest\\.config\\.(js|ts)$)",
-						"description": "Test files, mocks, and Jest configuration"
-					}
-				]
-			],
-			"customInstructions": "When writing tests:\n- Always use describe/it blocks for clear test organization\n- Include meaningful test descriptions\n- Use beforeEach/afterEach for proper test isolation\n- Implement proper error cases\n- Add JSDoc comments for complex test scenarios\n- Ensure mocks are properly typed\n- Verify both positive and negative test cases"
-		}
-	]
-}
+customModes:
+  - slug: translate
+    name: Translate
+    roleDefinition: You are Kilo Code, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.
+    groups:
+      - read
+      - - edit
+        - fileRegex: ((src/i18n/locales/)|(src/package\.nls(\.\w+)?\.json))
+          description: Translation files only
+    customInstructions: |-
+      When translating content:
+      - Maintain consistent terminology across all translations
+      - Respect the JSON structure of translation files
+      - Consider context when translating UI strings
+      - Watch for placeholders (like {{variable}}) and preserve them in translations
+      - Be mindful of text length in UI elements when translating to languages that might require more characters
+      - If you need context for a translation, use read_file to examine the components using these strings
+      - Specifically "Kilo", "Kilo Code" and similar terms are project names and proper nouns and must remain unchanged in translations
+  - slug: test
+    name: Test
+    roleDefinition: |-
+      You are Kilo Code, a Jest testing specialist with deep expertise in:
+      - Writing and maintaining Jest test suites
+      - Test-driven development (TDD) practices
+      - Mocking and stubbing with Jest
+      - Integration testing strategies
+      - TypeScript testing patterns
+      - Code coverage analysis
+      - Test performance optimization
+
+      Your focus is on maintaining high test quality and coverage across the codebase, working primarily with:
+      - Test files in __tests__ directories
+      - Mock implementations in __mocks__
+      - Test utilities and helpers
+      - Jest configuration and setup
+
+      You ensure tests are:
+      - Well-structured and maintainable
+      - Following Jest best practices
+      - Properly typed with TypeScript
+      - Providing meaningful coverage
+      - Using appropriate mocking strategies
+    groups:
+      - read
+      - browser
+      - command
+      - - edit
+        - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|/test/.*|jest\.config\.(js|ts)$)
+          description: Test files, mocks, and Jest configuration
+    customInstructions: |-
+      When writing tests:
+      - Always use describe/it blocks for clear test organization
+      - Include meaningful test descriptions
+      - Use beforeEach/afterEach for proper test isolation
+      - Implement proper error cases
+      - Add JSDoc comments for complex test scenarios
+      - Ensure mocks are properly typed
+      - Verify both positive and negative test cases

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-v20.19.2
+v20.20.0

+ 1 - 1
.tool-versions

@@ -1 +1 @@
-nodejs 20.19.2
+nodejs 20.20.0

+ 4 - 4
.vscode/launch.json

@@ -17,7 +17,7 @@
 			"env": {
 				"NODE_ENV": "development",
 				"VSCODE_DEBUG_MODE": "true",
-				"KILOCODE_DEV_CLI_PATH": "${workspaceFolder}/cli/dist/index.js"
+				"KILOCODE_DEV_AGENT_RUNTIME_PATH": "${workspaceFolder}/dist/agent-runtime-process.js"
 			},
 			"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
 			"presentation": {
@@ -42,7 +42,7 @@
 			"env": {
 				"NODE_ENV": "development",
 				"VSCODE_DEBUG_MODE": "true",
-				"KILOCODE_DEV_CLI_PATH": "${workspaceFolder}/cli/dist/index.js"
+				"KILOCODE_DEV_AGENT_RUNTIME_PATH": "${workspaceFolder}/dist/agent-runtime-process.js"
 			},
 			"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
 			"presentation": { "hidden": false, "group": "tasks", "order": 1 }
@@ -59,8 +59,8 @@
 			"env": {
 				"NODE_ENV": "development",
 				"VSCODE_DEBUG_MODE": "true",
-				"KILOCODE_DEV_CLI_PATH": "${workspaceFolder}/cli/dist/index.js",
-				"KILOCODE_BACKEND_BASE_URL": "${input:kilocodeBackendBaseUrl}"
+				"KILOCODE_BACKEND_BASE_URL": "${input:kilocodeBackendBaseUrl}",
+				"KILOCODE_DEV_AGENT_RUNTIME_PATH": "${workspaceFolder}/dist/agent-runtime-process.js"
 			},
 			"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
 			"presentation": { "hidden": false, "group": "tasks", "order": 2 }

+ 1 - 56
.vscode/tasks.json

@@ -5,7 +5,7 @@
 	"tasks": [
 		{
 			"label": "watch",
-			"dependsOn": ["watch:pnpm", "watch:webview", "watch:bundle", "watch:tsc", "watch:cli"],
+			"dependsOn": ["watch:pnpm", "watch:webview", "watch:bundle", "watch:tsc"],
 			"presentation": {
 				"reveal": "never"
 			},
@@ -106,61 +106,6 @@
 				"reveal": "always"
 			}
 		},
-		{
-			"label": "watch:cli:setup",
-			"dependsOn": ["watch:pnpm"],
-			"type": "shell",
-			"command": "node -e \"require('fs').existsSync('cli/dist/node_modules') || process.exit(1)\" || pnpm --filter @kilocode/cli dev:setup",
-			"group": "build",
-			"presentation": {
-				"group": "watch:cli",
-				"reveal": "silent"
-			},
-			"problemMatcher": []
-		},
-		{
-			"label": "watch:cli:deps",
-			"dependsOn": ["watch:cli:setup"],
-			"type": "shell",
-			"command": "npx nodemon --watch cli/package.dist.json --exec \"pnpm --filter @kilocode/cli deps:install\"",
-			"group": "build",
-			"isBackground": true,
-			"presentation": {
-				"group": "watch:cli",
-				"reveal": "silent"
-			},
-			"problemMatcher": {
-				"pattern": { "regexp": "^$" },
-				"background": {
-					"activeOnStart": true,
-					"beginsPattern": "\\[nodemon\\] starting",
-					"endsPattern": "\\[nodemon\\] clean exit - waiting for changes before restart"
-				}
-			}
-		},
-		{
-			"label": "watch:cli",
-			"dependsOn": ["watch:cli:setup", "watch:cli:deps"],
-			"type": "shell",
-			"command": "pnpm --filter @kilocode/cli dev",
-			"group": "build",
-			"problemMatcher": {
-				"owner": "esbuild",
-				"pattern": {
-					"regexp": "^$"
-				},
-				"background": {
-					"activeOnStart": true,
-					"beginsPattern": "esbuild-problem-matcher#onStart",
-					"endsPattern": "esbuild-problem-matcher#onEnd"
-				}
-			},
-			"isBackground": true,
-			"presentation": {
-				"group": "watch:cli",
-				"reveal": "always"
-			}
-		},
 		{
 			"label": "storybook",
 			"type": "shell",

+ 93 - 2
AGENTS.md

@@ -18,6 +18,86 @@ Key source directories:
 - `src/api/providers/` - AI provider implementations (50+ providers)
 - `src/core/tools/` - Tool implementations (ReadFile, ApplyDiff, ExecuteCommand, etc.)
 - `src/services/` - Services (MCP, browser, checkpoints, code-index)
+- `packages/agent-runtime/` - Standalone agent runtime (runs extension without VS Code)
+
+## Agent Runtime Architecture
+
+The `@kilocode/agent-runtime` package enables running Kilo Code agents as isolated Node.js processes without VS Code.
+
+### How It Works
+
+```
+┌─────────────────────┐     fork()      ┌─────────────────────┐
+│  CLI / Manager      │ ───────────────▶│  Agent Process      │
+│                     │◀───── IPC ─────▶│  (extension host)   │
+└─────────────────────┘                 └─────────────────────┘
+```
+
+1. **ExtensionHost**: Hosts the Kilo Code extension with a complete VS Code API mock
+2. **MessageBridge**: Bidirectional IPC communication (request/response with timeout)
+3. **ExtensionService**: Orchestrates host and bridge lifecycle
+
+### Spawning Agents
+
+Agents are forked processes configured via the `AGENT_CONFIG` environment variable:
+
+```typescript
+import { fork } from "child_process"
+
+const agent = fork(require.resolve("@kilocode/agent-runtime/process"), [], {
+	env: {
+		AGENT_CONFIG: JSON.stringify({
+			workspace: "/path/to/project",
+			providerSettings: { apiProvider: "anthropic", apiKey: "..." },
+			mode: "code",
+			autoApprove: false,
+		}),
+	},
+	stdio: ["pipe", "pipe", "pipe", "ipc"],
+})
+
+agent.on("message", (msg) => {
+	if (msg.type === "ready") {
+		agent.send({ type: "sendMessage", payload: { type: "newTask", text: "Fix the bug" } })
+	}
+})
+```
+
+### Message Protocol
+
+| Direction      | Type           | Description                    |
+| -------------- | -------------- | ------------------------------ |
+| Parent → Agent | `sendMessage`  | Send user message to extension |
+| Parent → Agent | `injectConfig` | Update extension configuration |
+| Parent → Agent | `shutdown`     | Gracefully terminate agent     |
+| Agent → Parent | `ready`        | Agent initialized              |
+| Agent → Parent | `message`      | Extension message              |
+| Agent → Parent | `stateChange`  | State updated                  |
+
+### Detecting Agent Context
+
+Code running in agent processes can check for the `AGENT_CONFIG` environment variable. This is set by the agent manager when spawning processes:
+
+```typescript
+if (process.env.AGENT_CONFIG) {
+	// Running as spawned agent - disable worker pools, etc.
+}
+```
+
+### State Management Pattern
+
+The Agent Manager follows a **read-shared, write-isolated** pattern:
+
+- **Read**: Get config (models, API settings) from extension via `provider.getState()`
+- **Write**: Inject state via `AGENT_CONFIG` env var when spawning - each agent gets isolated config
+
+```typescript
+fork(agentRuntimePath, [], {
+	env: { AGENT_CONFIG: JSON.stringify({ workspace, providerSettings, mode, sessionId }) },
+})
+```
+
+This ensures parallel agents have independent state with no race conditions or file I/O conflicts.
 
 ## Build Commands
 
@@ -57,7 +137,11 @@ Brief description of the change
 - Use `patch` for fixes, `minor` for features, `major` for breaking changes
 - For CLI changes, use `"@kilocode/cli": patch` instead
 
-Keep changesets concise but well-written as they become part of release notes.
+Keep changesets concise and feature-oriented as they appear directly in release notes.
+
+- **Only for actual changes**: Documentation-only or internal tooling changes do not need a changeset.
+- **User-focused**: Avoid technical descriptions, code references, or PR numbers. Readers may not know the codebase.
+- **Concise**: Use a one-liner for small fixes. For larger features, a few words or a short sentence is sufficient.
 
 ## Fork Merge Process
 
@@ -94,6 +178,7 @@ Code in these directories is Kilo Code-specific and doesn't need markers:
 
 - `cli/` - CLI package
 - `jetbrains/` - JetBrains plugin
+- `agent-manager/` directories
 - Any path containing `kilocode` in filename or directory name
 - `src/services/ghost/` - Ghost service
 
@@ -129,7 +214,13 @@ Keep changes to core extension code minimal to reduce merge conflicts during ups
 
     - Never disable any lint rules without explicit user approval
 
-3. Styling Guidelines:
+3. Error Handling:
+
+    - Never use empty catch blocks - always log or handle the error
+    - Handle expected errors explicitly, or omit try-catch if the error should propagate
+    - Consider user impact when deciding whether to throw or log errors
+
+4. Styling Guidelines:
 
     - Use Tailwind CSS classes instead of inline style objects for new markup
     - VSCode CSS variables must be added to webview-ui/src/index.css before using them in Tailwind classes

+ 459 - 0
CHANGELOG.md

@@ -1,5 +1,464 @@
 # kilo-code
 
+## 5.5.0
+
+### Minor Changes
+
+- [#4890](https://github.com/Kilo-Org/kilocode/pull/4890) [`535e3d1`](https://github.com/Kilo-Org/kilocode/commit/535e3d1751255487b4a0217fbae6e7b357b85a56) Thanks [@Drilmo](https://github.com/Drilmo)! - feat(agent-manager): add YOLO mode toggle and session rename
+
+    **New Features:**
+
+    - Add YOLO mode toggle button in new agent form to enable/disable auto-approval of tools
+    - Add YOLO mode indicator (⚡) in session header and sidebar for sessions running in YOLO mode
+    - Add inline session rename - click on session title to edit
+
+    **Technical Details:**
+
+    - `yoloMode` maps to `autoApprove` config in agent-runtime
+    - Added translations for all 22 supported locales
+
+### Patch Changes
+
+- [#5744](https://github.com/Kilo-Org/kilocode/pull/5744) [`870cdd5`](https://github.com/Kilo-Org/kilocode/commit/870cdd57e7b096caca536ca0aa0da393a68eb730) Thanks [@fstanis](https://github.com/fstanis)! - Fix Opus 4.6 model name
+
+- [#5767](https://github.com/Kilo-Org/kilocode/pull/5767) [`57daae1`](https://github.com/Kilo-Org/kilocode/commit/57daae1c3765bd1c37ee5791cb465edc7bd9a861) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Update Discord link in docs footer to use kilo.ai/discord
+
+- [#5758](https://github.com/Kilo-Org/kilocode/pull/5758) [`25f0043`](https://github.com/Kilo-Org/kilocode/commit/25f0043f66248cb12c1c353c9cd9935a0d2d9d60) Thanks [@markijbema](https://github.com/markijbema)! - Minor improvement of auto-execute commands with input redirection
+
+## 5.4.1
+
+### Patch Changes
+
+- [#5695](https://github.com/Kilo-Org/kilocode/pull/5695) [`8097ad6`](https://github.com/Kilo-Org/kilocode/commit/8097ad63b455dca2224f2811af69a0333a43fd79) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Add support for GPT 5.3 codex in OpenAI Codex provider
+
+- [#5584](https://github.com/Kilo-Org/kilocode/pull/5584) [`bd34af4`](https://github.com/Kilo-Org/kilocode/commit/bd34af4170ec3146f1c9c8ca8d8df28502b4b1fa) Thanks [@Neonsy](https://github.com/Neonsy)! - Add a favorited-task checkbox to batch delete in task history.
+
+- [#4770](https://github.com/Kilo-Org/kilocode/pull/4770) [`abaf633`](https://github.com/Kilo-Org/kilocode/commit/abaf6334f22d14496e38151c329887346525f090) Thanks [@JustinReyes28](https://github.com/JustinReyes28)! - feat: Add new "devstral-2512" Mistral model configuration
+
+## 5.4.0
+
+### Minor Changes
+
+- [#4096](https://github.com/Kilo-Org/kilocode/pull/4096) [`4eb0646`](https://github.com/Kilo-Org/kilocode/commit/4eb06462f78ab7446b319e1736fa837e86e3f1df) Thanks [@OlivierBarbier](https://github.com/OlivierBarbier)! - Fix: Importing a configuration file blocks the configuration of provider parameters #2349
+
+### Patch Changes
+
+- [#5686](https://github.com/Kilo-Org/kilocode/pull/5686) [`e6c26b7`](https://github.com/Kilo-Org/kilocode/commit/e6c26b7e8e468a565017fb05958cd4814d69daa1) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Add Claude Opus 4.6 model with adaptive thinking support
+
+- [#4021](https://github.com/Kilo-Org/kilocode/pull/4021) [`b8a6c4e`](https://github.com/Kilo-Org/kilocode/commit/b8a6c4e6b4eab9397efbbaa04202f92816e5afd4) Thanks [@In-line](https://github.com/In-line)! - Add React Compiler integration to improve UI responsiveness
+
+## 5.3.0
+
+### Minor Changes
+
+- [#5649](https://github.com/Kilo-Org/kilocode/pull/5649) [`6fbb740`](https://github.com/Kilo-Org/kilocode/commit/6fbb74084f4090d42ad583dd6ce62c2d3f7826f2) Thanks [@iscekic](https://github.com/iscekic)! - send x-kilocode-mode header
+
+- [#5531](https://github.com/Kilo-Org/kilocode/pull/5531) [`66dbaf2`](https://github.com/Kilo-Org/kilocode/commit/66dbaf2dac3f0d1163b7a9409805d32a9a80af1c) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Add new welcome screen for improved onboarding
+
+### Patch Changes
+
+- [#5582](https://github.com/Kilo-Org/kilocode/pull/5582) [`dc669ab`](https://github.com/Kilo-Org/kilocode/commit/dc669ab484a3d015cea1cadb57271b58a23ef796) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Use brand-colored Kilo Code icons throughout the extension for better visibility
+
+- [#5616](https://github.com/Kilo-Org/kilocode/pull/5616) [`9e139f5`](https://github.com/Kilo-Org/kilocode/commit/9e139f50bc52913fa7e42d3ba4c9090263a14f0b) Thanks [@EloiRamos](https://github.com/EloiRamos)! - fix(ui): prevent TypeError when trimming input during model switching
+
+- [#2792](https://github.com/Kilo-Org/kilocode/pull/2792) [`907fb53`](https://github.com/Kilo-Org/kilocode/commit/907fb53aca1f70b1e3e2f91fbb3bcbdc6b514a48) Thanks [@Honyii](https://github.com/Honyii)! - Added CONTRIBUTING.md file for onboarding new contributors
+
+- [#5638](https://github.com/Kilo-Org/kilocode/pull/5638) [`a5b9106`](https://github.com/Kilo-Org/kilocode/commit/a5b9106e6cebc1a63c1ef5fa507cfaab65aa8ebc) Thanks [@Drilmo](https://github.com/Drilmo)! - fix(agent-manager): sync messages when panel is reopened
+
+    Fixed a bug where closing and reopening the Agent Manager panel would show "Waiting for agent response..." instead of the conversation messages.
+
+- [#5644](https://github.com/Kilo-Org/kilocode/pull/5644) [`e3f353f`](https://github.com/Kilo-Org/kilocode/commit/e3f353f596288b9b8e60b00fa88e60f179160c9a) Thanks [@bernaferrari](https://github.com/bernaferrari)! - Fix contrast on "ideas" intro screen
+
+- [#5583](https://github.com/Kilo-Org/kilocode/pull/5583) [`a23c936`](https://github.com/Kilo-Org/kilocode/commit/a23c9361a5a15cf7bd59efd9c8ea9987e2ec82cc) Thanks [@crazyrabbit0](https://github.com/crazyrabbit0)! - Fix double scroll bar in ModelSelector and KiloProfileSelector by increasing max-height.
+
+- [#5567](https://github.com/Kilo-Org/kilocode/pull/5567) [`9729ab2`](https://github.com/Kilo-Org/kilocode/commit/9729ab2c808a69fadbb8c095e5a626fa75e42859) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Updated chat UI theme to use muted, theme-aware colors for Checkpoint, Thinking, and user message styling
+
+- [#5577](https://github.com/Kilo-Org/kilocode/pull/5577) [`a57f9ac`](https://github.com/Kilo-Org/kilocode/commit/a57f9acb2c07b0888fcfa566c2d345879f890941) Thanks [@Patel230](https://github.com/Patel230)! - fix: allow Ollama models without tool support for autocomplete
+
+- [#5628](https://github.com/Kilo-Org/kilocode/pull/5628) [`84c6db2`](https://github.com/Kilo-Org/kilocode/commit/84c6db2ff906b6d18625dc0de21a77a0e573f4ac) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Prevent chat auto-scroll from jumping while you read older messages.
+
+- [#5214](https://github.com/Kilo-Org/kilocode/pull/5214) [`28a46d1`](https://github.com/Kilo-Org/kilocode/commit/28a46d17fe91f13ec0687bb6834b31e2ec454687) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Add GLM-4.7 Flash model to recommended models list for Z.ai provider
+
+- [#5662](https://github.com/Kilo-Org/kilocode/pull/5662) [`228745b`](https://github.com/Kilo-Org/kilocode/commit/228745b4159cd28b7a8fb8d1db1b89e9beb49539) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Add improved support for Kimi 2.5 reasoning through AI SDK
+
+## 5.2.2
+
+### Patch Changes
+
+- [#5497](https://github.com/Kilo-Org/kilocode/pull/5497) [`95f9214`](https://github.com/Kilo-Org/kilocode/commit/95f92143d254741e6e0628f43ad90a3464fa7a09) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Show sign in prompt when trying paid model when not logged in
+
+- [#5529](https://github.com/Kilo-Org/kilocode/pull/5529) [`1fe7b92`](https://github.com/Kilo-Org/kilocode/commit/1fe7b929e1c218614b9ae71270b304ab47dbf894) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Streamline getting started view: move logo to top, reduce suggestions to 2, remove footer hint text
+
+## 5.2.1
+
+### Patch Changes
+
+- [#5501](https://github.com/Kilo-Org/kilocode/pull/5501) [`cecefc1`](https://github.com/Kilo-Org/kilocode/commit/cecefc1dd660100631eecf8517f2c0c918f6cdb3) Thanks [@Neonsy](https://github.com/Neonsy)! - Adding Kimi K2.5
+
+## 5.2.0
+
+### Minor Changes
+
+- [#5477](https://github.com/Kilo-Org/kilocode/pull/5477) [`59a792e`](https://github.com/Kilo-Org/kilocode/commit/59a792eeb461497fe2968ca17e2858389c55894a) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Improve idea box during onboarding experience
+
+### Patch Changes
+
+- [#5503](https://github.com/Kilo-Org/kilocode/pull/5503) [`e53f086`](https://github.com/Kilo-Org/kilocode/commit/e53f0865d32296cb5e4db5f853466f5fa7671371) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Fix mode selection after anonymous usage
+
+- [#5426](https://github.com/Kilo-Org/kilocode/pull/5426) [`56d086b`](https://github.com/Kilo-Org/kilocode/commit/56d086b4853abfeebff6b1afb6c8d0431c232542) Thanks [@lambertjosh](https://github.com/lambertjosh)! - OpenAI Codex: Add ChatGPT subscription usage limits dashboard
+
+- [#4947](https://github.com/Kilo-Org/kilocode/pull/4947) [`53080fd`](https://github.com/Kilo-Org/kilocode/commit/53080fddfc62a171ebae09fe38629aec8b0e6098) Thanks [@CaiDingxian](https://github.com/CaiDingxian)! - feat(moonshot): add new Kimi models and coding API endpoint
+
+- [#5451](https://github.com/Kilo-Org/kilocode/pull/5451) [`af25644`](https://github.com/Kilo-Org/kilocode/commit/af25644f8482bd1a10e6645ed3061421ac23045e) Thanks [@kiloconnect](https://github.com/apps/kiloconnect)! - Updated welcome screen model names in all translations
+
+## 5.1.0
+
+### Minor Changes
+
+- [`8140071`](https://github.com/Kilo-Org/kilocode/commit/8140071cf0235906d06e14034372af5941b0b9cc) Thanks [@markijbema](https://github.com/markijbema)! - New users can now start using Kilo Code immediately without any configuration - a default Kilo Code Gateway profile with a free model is automatically set up on first launch
+
+- [#5288](https://github.com/Kilo-Org/kilocode/pull/5288) [`016ea49`](https://github.com/Kilo-Org/kilocode/commit/016ea49a3a875a8e60c846b314a7040852701262) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Remove Gemini CLI provider support.
+
+### Patch Changes
+
+- [#5420](https://github.com/Kilo-Org/kilocode/pull/5420) [`ebcfca8`](https://github.com/Kilo-Org/kilocode/commit/ebcfca8ea1dd3aad87e3a2598370208a1daaddc6) Thanks [@pedroheyerdahl](https://github.com/pedroheyerdahl)! - Improved Portuguese (Brazil) translation
+
+## 5.0.0
+
+### Major Changes
+
+- [#5400](https://github.com/Kilo-Org/kilocode/pull/5400) [`5a49128`](https://github.com/Kilo-Org/kilocode/commit/5a49128a570f1725b705b2da7b19486649e526ed) Thanks [@Sureshkumars](https://github.com/Sureshkumars)! - Add Local review mode
+
+### Minor Changes
+
+- [#5234](https://github.com/Kilo-Org/kilocode/pull/5234) [`796e188`](https://github.com/Kilo-Org/kilocode/commit/796e188f6213f8093e3e6cadd5b019d55993f948) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Include changes from Roo Code v3.39.0-v3.41.2
+
+    - Add button to open markdown in VSCode preview for easier reading of formatted content (PR #10773 by @brunobergher)
+    - Fix: Add openai-codex to providers that don't require an API key (PR #10786 by @roomote)
+    - Fix: Detect Gemini models with space-separated names for proper thought signature injection in LiteLLM (PR #10787 by @daniel-lxs)
+    - Feat: Aggregate subtask costs in parent task (#5376 by @hannesrudolph, PR #10757 by @taltas)
+    - Fix: Prevent duplicate tool_use IDs causing API 400 errors (PR #10760 by @daniel-lxs)
+    - Fix: Handle missing tool identity in OpenAI Native streams (PR #10719 by @hannesrudolph)
+    - Fix: Truncate call_id to 64 chars for OpenAI Responses API (PR #10763 by @daniel-lxs)
+    - Fix: Gemini thought signature validation errors (PR #10694 by @daniel-lxs)
+    - Fix: Filter out empty text blocks from user messages for Gemini compatibility (PR #10728 by @daniel-lxs)
+    - Fix: Flatten top-level anyOf/oneOf/allOf in MCP tool schemas (PR #10726 by @daniel-lxs)
+    - Fix: Filter Ollama models without native tool support (PR #10735 by @daniel-lxs)
+    - Feat: Add settings tab titles to search index (PR #10761 by @roomote)
+    - Fix: Clear terminal output buffers to prevent memory leaks that could cause gray screens and performance degradation (#10666, PR #7666 by @hannesrudolph)
+    - Fix: Inject dummy thought signatures on ALL tool calls for Gemini models, resolving issues with Gemini tool call handling through LiteLLM (PR #10743 by @daniel-lxs)
+    - Fix: Add allowedFunctionNames support for Gemini to prevent mode switch errors (#10711 by @hannesrudolph, PR #10708 by @hannesrudolph)
+    - Add settings search functionality to quickly find and navigate to specific settings (PR #10619 by @mrubens)
+    - Improve settings search UI with better styling and usability (PR #10633 by @brunobergher)
+    - Display edit_file errors in UI after consecutive failures for better debugging feedback (PR #10581 by @daniel-lxs)
+    - Improve error display styling and visibility in chat messages (PR #10692 by @brunobergher)
+    - Improve stop button visibility and streamline error handling (PR #10696 by @brunobergher)
+    - Fix: Omit parallel_tool_calls when not explicitly enabled to prevent API errors (#10553 by @Idlebrand, PR #10671 by @daniel-lxs)
+    - Fix: Encode hyphens in MCP tool names before sanitization (#10642 by @pdecat, PR #10644 by @pdecat)
+    - Fix: Correct Gemini 3 thought signature injection format via OpenRouter (PR #10640 by @daniel-lxs)
+    - Fix: Sanitize tool_use IDs to match API validation pattern (PR #10649 by @daniel-lxs)
+    - Fix: Use placeholder for empty tool result content to fix Gemini API validation (PR #10672 by @daniel-lxs)
+    - Fix: Return empty string from getReadablePath when path is empty (PR #10638 by @daniel-lxs)
+    - Optimize message block cloning in presentAssistantMessage for better performance (PR #10616 by @ArchimedesCrypto)
+    - Improve ExtensionHost code organization and cleanup (PR #10600 by @cte)
+    - Fix: Ensure all tools have consistent strict mode values for Cerebras compatibility (#10334 by @brianboysen51, PR #10589 by @app/roomote)
+    - Fix: Remove convertToSimpleMessages to restore tool calling for OpenAI-compatible providers (PR #10575 by @daniel-lxs)
+    - Fix: Make edit_file matching more resilient to prevent false negatives (PR #10585 by @hannesrudolph)
+    - Fix: Order text parts before tool calls in assistant messages for vscode-lm (PR #10573 by @daniel-lxs)
+    - Fix: Ensure assistant message content is never undefined for Gemini compatibility (PR #10559 by @daniel-lxs)
+    - Fix: Merge approval feedback into tool result instead of pushing duplicate messages (PR #10519 by @daniel-lxs)
+    - Fix: Round-trip Gemini thought signatures for tool calls (PR #10590 by @hannesrudolph)
+    - Feature: Improve error messaging for stream termination errors from provider (PR #10548 by @daniel-lxs)
+    - Feature: Add debug setting to settings page for easier troubleshooting (PR #10580 by @hannesrudolph)
+    - Chore: Disable edit_file tool for Gemini/Vertex providers (PR #10594 by @hannesrudolph)
+    - Chore: Stop overriding tool allow/deny lists for Gemini (PR #10592 by @hannesrudolph)
+    - Fix: Stabilize file paths during native tool call streaming to prevent path corruption (PR #10555 by @daniel-lxs)
+    - Fix: Disable Gemini thought signature persistence to prevent corrupted signature errors (PR #10554 by @daniel-lxs)
+    - Fix: Change minItems from 2 to 1 for Anthropic API compatibility (PR #10551 by @daniel-lxs)
+    - Implement sticky provider profile for task-level API config persistence (#8010 by @hannesrudolph, PR #10018 by @hannesrudolph)
+    - Add support for image file @mentions (PR #10189 by @hannesrudolph)
+    - Add debug-mode proxy routing for debugging API calls (#7042 by @SleeperSmith, PR #10467 by @hannesrudolph)
+    - Add Kimi K2 thinking model to Fireworks AI provider (#9201 by @kavehsfv, PR #9202 by @roomote)
+    - Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote)
+    - Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras)
+    - Tweak the style of follow up suggestion modes (PR #9260 by @mrubens)
+    - Fix: Handle PowerShell ENOENT error in os-name on Windows (#9859 by @Yang-strive, PR #9897 by @roomote)
+    - Fix: Make command chaining examples shell-aware for Windows compatibility (#10352 by @AlexNek, PR #10434 by @roomote)
+    - Fix: Preserve tool_use blocks for all tool_results in kept messages during condensation (PR #10471 by @daniel-lxs)
+    - Fix: Add additionalProperties: false to MCP tool schemas for OpenAI Responses API (PR #10472 by @daniel-lxs)
+    - Fix: Prevent duplicate tool_result blocks causing API errors (PR #10497 by @daniel-lxs)
+    - Fix: Add explicit deduplication for duplicate tool_result blocks (#10465 by @nabilfreeman, PR #10466 by @roomote)
+    - Fix: Use task stored API config as fallback for rate limit (PR #10266 by @roomote)
+    - Fix: Remove legacy Claude 2 series models from Bedrock provider (#9220 by @KevinZhao, PR #10501 by @roomote)
+    - Fix: Add missing description fields for debugProxy configuration (PR #10505 by @roomote) @objectiveSee)
+
+### Patch Changes
+
+- [#5354](https://github.com/Kilo-Org/kilocode/pull/5354) [`7156a35`](https://github.com/Kilo-Org/kilocode/commit/7156a35649d97a10694229a8a89fd10c5a9f9607) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fixed broken image display in Agent Manager message list. Images pasted or attached to messages now render correctly as thumbnails in both user feedback messages and queued messages.
+
+- [#5373](https://github.com/Kilo-Org/kilocode/pull/5373) [`cb41705`](https://github.com/Kilo-Org/kilocode/commit/cb41705691d4be7dc915d9d2f42fbcfaa033d9a8) Thanks [@sebastiand-cerebras](https://github.com/sebastiand-cerebras)! - Set default temperature to 1.0 for Cerebras zai-glm-4.7 model
+
+- [#5402](https://github.com/Kilo-Org/kilocode/pull/5402) [`930931e`](https://github.com/Kilo-Org/kilocode/commit/930931eefe2d5da11ef1b98dc2f8145cb26feb2f) Thanks [@PeterDaveHello](https://github.com/PeterDaveHello)! - Improve zh-TW translations
+
+- [#5407](https://github.com/Kilo-Org/kilocode/pull/5407) [`77cfa54`](https://github.com/Kilo-Org/kilocode/commit/77cfa54e05dbd57d8c2e333da67b1b049bdebdf8) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Add native tool calling support to Nano-GPT provider
+
+- [#5396](https://github.com/Kilo-Org/kilocode/pull/5396) [`fdae881`](https://github.com/Kilo-Org/kilocode/commit/fdae881bfb3483066117db54bb85c7497d4bff5f) Thanks [@markijbema](https://github.com/markijbema)! - Revert "Using Kilo for work?" button in low credit warning, restore free models link
+
+- [#5364](https://github.com/Kilo-Org/kilocode/pull/5364) [`5e8ed35`](https://github.com/Kilo-Org/kilocode/commit/5e8ed3526110f6868b8b8af203eb3e733493a387) Thanks [@huangdaxianer](https://github.com/huangdaxianer)! - Removed forced context compression for volces.com
+
+## 4.153.0
+
+### Minor Changes
+
+- [#5330](https://github.com/Kilo-Org/kilocode/pull/5330) [`957df89`](https://github.com/Kilo-Org/kilocode/commit/957df89a92d951c409952e16948694488abce474) Thanks [@qbiecom](https://github.com/qbiecom)! - Added OpenAI Compatible (Responses) provider
+
+### Patch Changes
+
+- [#5337](https://github.com/Kilo-Org/kilocode/pull/5337) [`fbe1e77`](https://github.com/Kilo-Org/kilocode/commit/fbe1e77e56e27d075f93a32006abf2fef9ee08e2) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Increased Agent Manager initial prompt input size for easier editing of longer prompts
+
+- [#5340](https://github.com/Kilo-Org/kilocode/pull/5340) [`1e7e7ef`](https://github.com/Kilo-Org/kilocode/commit/1e7e7efd42d5a735442ceb55e321271057735f7b) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fixed CLI file duplication bug where content was written twice when creating or editing files
+
+## 4.152.0
+
+### Minor Changes
+
+- [#5211](https://github.com/Kilo-Org/kilocode/pull/5211) [`a94f8f0`](https://github.com/Kilo-Org/kilocode/commit/a94f8f06c561027158356858bf6642927794b2a9) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Add mode selection to Agent Manager for CLI sessions
+
+    - Mode selector in new agent form allows selecting mode (code, architect, debug, etc.) when starting sessions
+    - Mode selector in session header allows switching modes during running sessions via CLI JSON-IO API
+    - Modes are fetched from extension and synced with CLI sessions
+    - Model selector moved below textarea in new agent form for better layout
+
+- [#5264](https://github.com/Kilo-Org/kilocode/pull/5264) [`61af1e7`](https://github.com/Kilo-Org/kilocode/commit/61af1e74c24e8a2af99b218da69d51b3000d2f0f) Thanks [@markijbema](https://github.com/markijbema)! - Centralize Agent behaviour settings by removing the top bar MCP button and moving Mode, MCP, Rules, and Workflows configuration into the Agent Behaviour area.
+
+### Patch Changes
+
+- [#5312](https://github.com/Kilo-Org/kilocode/pull/5312) [`322d891`](https://github.com/Kilo-Org/kilocode/commit/322d891c5461deada1cc1c5057bde5cf7eb774d1) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Add loading spinner to agent manager API request messages
+
+- [#5233](https://github.com/Kilo-Org/kilocode/pull/5233) [`86bcfee`](https://github.com/Kilo-Org/kilocode/commit/86bcfee20a672d9e06a86b86c7d7cec28d0a8913) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Add session persistence for Agent Manager worktrees
+
+- [#5313](https://github.com/Kilo-Org/kilocode/pull/5313) [`c882b95`](https://github.com/Kilo-Org/kilocode/commit/c882b9558c39abffbdced575939be7b2125be0e2) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fixed agent-manager mode creating `.kilocode-agent` directory in user workspaces. Agent storage now uses OS temp directory instead, keeping workspaces clean.
+
+- [#5315](https://github.com/Kilo-Org/kilocode/pull/5315) [`f0a9036`](https://github.com/Kilo-Org/kilocode/commit/f0a9036b766ab8a0b1158be804bdeb256f476596) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix model selection not showing in resumed sessions in Agent Manager
+
+- [#5232](https://github.com/Kilo-Org/kilocode/pull/5232) [`cc04a57`](https://github.com/Kilo-Org/kilocode/commit/cc04a5719ca5b457e38b7bacdab2c6dac92cf297) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix parallel mode completion messaging when commits fail.
+
+- [#5151](https://github.com/Kilo-Org/kilocode/pull/5151) [`5565a7c`](https://github.com/Kilo-Org/kilocode/commit/5565a7c15544630b11297f40f4e948588943b893) Thanks [@Senneseph](https://github.com/Senneseph)! - Fix: Check that `model_info` field exists before attempting to call Object.keys() on it.
+
+- [#5314](https://github.com/Kilo-Org/kilocode/pull/5314) [`f202bd5`](https://github.com/Kilo-Org/kilocode/commit/f202bd55756a0382dc6abb619ddbb1e7451343b5) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Display reasoning as collapsible block in Agent Manager instead of plain text
+
+- [#5287](https://github.com/Kilo-Org/kilocode/pull/5287) [`4662b02`](https://github.com/Kilo-Org/kilocode/commit/4662b02f0a8fa2b5cb95120c6c1ef7984508d0f3) Thanks [@markijbema](https://github.com/markijbema)! - Add Skills tab to Agent Behaviour settings for viewing and managing installed skills
+
+- [#5297](https://github.com/Kilo-Org/kilocode/pull/5297) [`f6badf7`](https://github.com/Kilo-Org/kilocode/commit/f6badf709982890fca245b1e079d041efddbfc26) Thanks [@jrf0110](https://github.com/jrf0110)! - feat(mcp): implement oauth 2.1 authorization for http transports
+
+- [#5254](https://github.com/Kilo-Org/kilocode/pull/5254) [`9348a3d`](https://github.com/Kilo-Org/kilocode/commit/9348a3d33d68ff61340e90a2647c1026752ea66a) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Force tool use when using Haiku with the Anthropic provider
+
+## 4.151.0
+
+### Minor Changes
+
+- [#5270](https://github.com/Kilo-Org/kilocode/pull/5270) [`6839f7c`](https://github.com/Kilo-Org/kilocode/commit/6839f7c76438b159873c5c88523324515809b8a0) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Add support for OpenAI Codex subscriptions (thanks Roo)
+
+    - Fix: Reset invalid model selection when using OpenAI Codex provider (PR #10777 by @hannesrudolph)
+    - Add OpenAI - ChatGPT Plus/Pro Provider that gives subscription-based access to Codex models without per-token costs (PR #10736 by @hannesrudolph)
+
+## 4.150.0
+
+### Minor Changes
+
+- [#5239](https://github.com/Kilo-Org/kilocode/pull/5239) [`ff1500d`](https://github.com/Kilo-Org/kilocode/commit/ff1500d75f4cefee6b7fd7fd1e126339b147255d) Thanks [@markijbema](https://github.com/markijbema)! - Added Skills Marketplace tab alongside existing MCP and Modes marketplace tabs
+
+### Patch Changes
+
+- [#5193](https://github.com/Kilo-Org/kilocode/pull/5193) [`ff3cbe5`](https://github.com/Kilo-Org/kilocode/commit/ff3cbe521bbcccfc18a7b37cd69a190c0291badb) Thanks [@mayef](https://github.com/mayef)! - Fix Cerebras provider to ensure all tools have consistent strict mode values
+
+- [#5208](https://github.com/Kilo-Org/kilocode/pull/5208) [`f770cec`](https://github.com/Kilo-Org/kilocode/commit/f770cecf01d037ed9da31114603940f2a66a145a) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix terminal button showing "Session not found" for remote sessions in Agent Manager
+
+- [#5213](https://github.com/Kilo-Org/kilocode/pull/5213) [`553fc58`](https://github.com/Kilo-Org/kilocode/commit/553fc58293a73b62793ca9e05921bf6e413e0c85) Thanks [@jrf0110](https://github.com/jrf0110)! - Add AI Attribution line tracking to the EditFileTool
+
+- [#5240](https://github.com/Kilo-Org/kilocode/pull/5240) [`6d297fb`](https://github.com/Kilo-Org/kilocode/commit/6d297fb8fe1d33aa58b941a0bb903c1847996407) Thanks [@catrielmuller](https://github.com/catrielmuller)! - Jetbrains - Fix Autocomplete
+
+- [#5044](https://github.com/Kilo-Org/kilocode/pull/5044) [`2ee6e82`](https://github.com/Kilo-Org/kilocode/commit/2ee6e822b6d7fabb2d136dd03117c469b00ee51d) Thanks [@jrf0110](https://github.com/jrf0110)! - Add GitHub-style diff stats display to task header showing lines added/removed in real-time
+
+- [#5228](https://github.com/Kilo-Org/kilocode/pull/5228) [`b834a25`](https://github.com/Kilo-Org/kilocode/commit/b834a25ea075fac7b95762e2355cf04d05d2633e) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Fallbacks are now allowed when selecting a specific OpenRouter provider
+
+## 4.149.0
+
+### Minor Changes
+
+- [#5176](https://github.com/Kilo-Org/kilocode/pull/5176) [`6765832`](https://github.com/Kilo-Org/kilocode/commit/676583256cb405ef8fb8008f313bfe4a090e9ba0) Thanks [@Drilmo](https://github.com/Drilmo)! - Add image support to Agent Manager
+
+    - Paste images from clipboard (Ctrl/Cmd+V) or select via file browser button
+    - Works in new agent prompts, follow-up messages, and resumed sessions
+    - Support for PNG, JPEG, WebP, and GIF formats (up to 4 images per message)
+    - Click thumbnails to preview, hover to remove
+    - New `newTask` stdin message type for initial prompts with images
+    - Temp image files are automatically cleaned up when extension deactivates
+
+### Patch Changes
+
+- [#5179](https://github.com/Kilo-Org/kilocode/pull/5179) [`aff6137`](https://github.com/Kilo-Org/kilocode/commit/aff613714afe752fffba01ed5958d6123426b69c) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Fix duplicate tool_result blocks when users approve tool execution with feedback text
+
+    Cherry-picked from upstream Roo-Code:
+
+    - [#10466](https://github.com/RooCodeInc/Roo-Code/pull/10466) - Add explicit deduplication (thanks @daniel-lxs)
+    - [#10519](https://github.com/RooCodeInc/Roo-Code/pull/10519) - Merge approval feedback into tool result (thanks @daniel-lxs)
+
+- [#5200](https://github.com/Kilo-Org/kilocode/pull/5200) [`495e5ff`](https://github.com/Kilo-Org/kilocode/commit/495e5ffad395fa49626a2e4992e82c690f0be8c7) Thanks [@catrielmuller](https://github.com/catrielmuller)! - - Fixed webview flickering in JetBrains plugin for smoother UI rendering
+
+    - Improved thread management in JetBrains plugin to prevent UI freezes
+
+- [#5194](https://github.com/Kilo-Org/kilocode/pull/5194) [`fe6c025`](https://github.com/Kilo-Org/kilocode/commit/fe6c02510bd969eb3f7212804bd330beaa9fc4cb) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Improved the reliability of the read_file tool when using Claude models
+
+- [#5078](https://github.com/Kilo-Org/kilocode/pull/5078) [`d4cc35d`](https://github.com/Kilo-Org/kilocode/commit/d4cc35ddb86ef9d0165e4d61323fa9a0920f2ba7) Thanks [@markijbema](https://github.com/markijbema)! - Remove clipboard reading from chat autocomplete
+
+- Updated dependencies [[`6765832`](https://github.com/Kilo-Org/kilocode/commit/676583256cb405ef8fb8008f313bfe4a090e9ba0), [`cdc3e2e`](https://github.com/Kilo-Org/kilocode/commit/cdc3e2ea32ced833b9d1d1983a4252eda3c0fdf1)]:
+    - @kilocode/[email protected]
+
+## 4.148.1
+
+### Patch Changes
+
+- [#5138](https://github.com/Kilo-Org/kilocode/pull/5138) [`e5d08e5`](https://github.com/Kilo-Org/kilocode/commit/e5d08e5464ee85a50cbded2af5a2d0bd3a5390e2) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - fix: prevent duplicate tool_result blocks causing API errors (thanks @daniel-lxs)
+
+- [#5118](https://github.com/Kilo-Org/kilocode/pull/5118) [`9ff3a91`](https://github.com/Kilo-Org/kilocode/commit/9ff3a919ecc9430c8c6c71659cfe1fa734d92877) Thanks [@lambertjosh](https://github.com/lambertjosh)! - Fix model search matching for free tags.
+
+## 4.148.0
+
+### Minor Changes
+
+- [#4903](https://github.com/Kilo-Org/kilocode/pull/4903) [`db67550`](https://github.com/Kilo-Org/kilocode/commit/db6755024b651ec8401e90935a8185f3c9a145c8) Thanks [@eliasto](https://github.com/eliasto)! - feat(ovhcloud): Add native function calling support
+
+### Patch Changes
+
+- [#5073](https://github.com/Kilo-Org/kilocode/pull/5073) [`ab88311`](https://github.com/Kilo-Org/kilocode/commit/ab883117517b2037e23ab67c68874846be3e5c7c) Thanks [@jrf0110](https://github.com/jrf0110)! - Supports AI Attribution and code formatters format on save. Previously, the AI attribution service would not account for the fact that after saving, the AI generated code would completely change based on the user's configured formatter. This change fixes the issue by using the formatted result for attribution.
+
+- [#5106](https://github.com/Kilo-Org/kilocode/pull/5106) [`a55d1a5`](https://github.com/Kilo-Org/kilocode/commit/a55d1a58a6d127d8649baa95c1a526e119b984fe) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix slow CLI termination when pressing Ctrl+C during prompt selection
+
+    MCP server connection cleanup now uses fire-and-forget pattern for transport.close() and client.close() calls, which could previously block for 2+ seconds if MCP servers were unresponsive. This ensures fast exit behavior when the user wants to quit quickly.
+
+- [#5102](https://github.com/Kilo-Org/kilocode/pull/5102) [`7a528c4`](https://github.com/Kilo-Org/kilocode/commit/7a528c42e1de49336b914ca0cbd58057a16259ad) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Partial reads are now allowed by default, prevent the context to grow too quickly.
+
+- Updated dependencies [[`b2e2630`](https://github.com/Kilo-Org/kilocode/commit/b2e26304e562e516383fbf95a3fdc668d88e1487)]:
+    - @kilocode/[email protected]
+
+## 4.147.0
+
+### Minor Changes
+
+- [#5023](https://github.com/Kilo-Org/kilocode/pull/5023) [`879bd5d`](https://github.com/Kilo-Org/kilocode/commit/879bd5d6aa8d8e422cf0711ab2729abec10ee511) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Agent Manager now lets you choose which AI model to use when starting a new session. Your model selection is remembered across panel reopens, and active sessions display the model being used.
+
+### Patch Changes
+
+- [#5060](https://github.com/Kilo-Org/kilocode/pull/5060) [`ce99875`](https://github.com/Kilo-Org/kilocode/commit/ce998755310094117d687cc271e117005a46cd90) Thanks [@DoubleDoubleBonus](https://github.com/DoubleDoubleBonus)! - Add OpenAI Native model option gpt-5.2-codex.
+
+- [#4686](https://github.com/Kilo-Org/kilocode/pull/4686) [`2bd899e`](https://github.com/Kilo-Org/kilocode/commit/2bd899eede90bc1e11b32cce55dd52f3e7ac9323) Thanks [@Ashwinhegde19](https://github.com/Ashwinhegde19)! - Fix BrowserSessionRow crash on non-string inputs
+
+- [#4381](https://github.com/Kilo-Org/kilocode/pull/4381) [`e37b839`](https://github.com/Kilo-Org/kilocode/commit/e37b8397bcd1f8bd8742e29b1af8edabc5ddf9db) Thanks [@inj-src](https://github.com/inj-src)! - fix: better chat view by limiting the maximum width
+
+- [#5028](https://github.com/Kilo-Org/kilocode/pull/5028) [`885a54a`](https://github.com/Kilo-Org/kilocode/commit/885a54aae6c43620c431eeb055794f00f2dada0b) Thanks [@chrarnoldus](https://github.com/chrarnoldus)! - Visual Studio Code's telemetry setting is now respected
+
+- [#4406](https://github.com/Kilo-Org/kilocode/pull/4406) [`7dd14bd`](https://github.com/Kilo-Org/kilocode/commit/7dd14bd35c7aa82bdcbe179a6b1141735778b5a2) Thanks [@Secsys-FDU](https://github.com/Secsys-FDU)! - fix: block Windows CMD injection vectors in auto-approved commands
+
+## 4.146.0
+
+### Minor Changes
+
+- [#4865](https://github.com/Kilo-Org/kilocode/pull/4865) [`d9e65fe`](https://github.com/Kilo-Org/kilocode/commit/d9e65fe1027943a51cfc1dd97c2eed86ed104748) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - Include changes from Roo Code v3.36.7-v3.38.3
+
+    - Feat: Add option in Context settings to recursively load `.kilocode/rules` and `AGENTS.md` from subdirectories (PR #10446 by @mrubens)
+    - Fix: Stop frequent Claude Code sign-ins by hardening OAuth refresh token handling (PR #10410 by @hannesrudolph)
+    - Fix: Add `maxConcurrentFileReads` limit to native `read_file` tool schema (PR #10449 by @app/roomote)
+    - Fix: Add type check for `lastMessage.text` in TTS useEffect to prevent runtime errors (PR #10431 by @app/roomote)
+    - Align skills system with Agent Skills specification (PR #10409 by @hannesrudolph)
+    - Prevent write_to_file from creating files at truncated paths (PR #10415 by @mrubens and @daniel-lxs)
+    - Fix rate limit wait display (PR #10389 by @hannesrudolph)
+    - Remove human-relay provider (PR #10388 by @hannesrudolph)
+    - Fix: Flush pending tool results before condensing context (PR #10379 by @daniel-lxs)
+    - Fix: Revert mergeToolResultText for OpenAI-compatible providers (PR #10381 by @hannesrudolph)
+    - Fix: Enforce maxConcurrentFileReads limit in read_file tool (PR #10363 by @roomote)
+    - Fix: Improve feedback message when read_file is used on a directory (PR #10371 by @roomote)
+    - Fix: Handle custom tool use similarly to MCP tools for IPC schema purposes (PR #10364 by @jr)
+    - Add support for npm packages and .env files to custom tools, allowing custom tools to import dependencies and access environment variables (PR #10336 by @cte)
+    - Remove simpleReadFileTool feature, streamlining the file reading experience (PR #10254 by @app/roomote)
+    - Remove OpenRouter Transforms feature (PR #10341 by @app/roomote)
+    - Fix: Send native tool definitions by default for OpenAI to ensure proper tool usage (PR #10314 by @hannesrudolph)
+    - Fix: Preserve reasoning_details shape to prevent malformed responses when processing model output (PR #10313 by @hannesrudolph)
+    - Fix: Drain queued messages while waiting for ask to prevent message loss (PR #10315 by @hannesrudolph)
+    - Feat: Add grace retry for empty assistant messages to improve reliability (PR #10297 by @hannesrudolph)
+    - Feat: Enable mergeToolResultText for all OpenAI-compatible providers for better tool result handling (PR #10299 by @hannesrudolph)
+    - Feat: Strengthen native tool-use guidance in prompts for improved model behavior (PR #10311 by @hannesrudolph)
+    - Add MiniMax M2.1 and improve environment_details handling for Minimax thinking models (PR #10284 by @hannesrudolph)
+    - Add GLM-4.7 model with thinking mode support for Zai provider (PR #10282 by @hannesrudolph)
+    - Add experimental custom tool calling - define custom tools that integrate seamlessly with your AI workflow (PR #10083 by @cte)
+    - Deprecate XML tool protocol selection and force native tool format for new tasks (PR #10281 by @daniel-lxs)
+    - Fix: Emit tool_call_end events in OpenAI handler when streaming ends (#10275 by @torxeon, PR #10280 by @daniel-lxs)
+    - Fix: Emit tool_call_end events in BaseOpenAiCompatibleProvider (PR #10293 by @hannesrudolph)
+    - Fix: Disable strict mode for MCP tools to preserve optional parameters (PR #10220 by @daniel-lxs)
+    - Fix: Move array-specific properties into anyOf variant in normalizeToolSchema (PR #10276 by @daniel-lxs)
+    - Fix: Add graceful fallback for model parsing in Chutes provider (PR #10279 by @hannesrudolph)
+    - Fix: Enable Requesty refresh models with credentials (PR #10273 by @daniel-lxs)
+    - Fix: Improve reasoning_details accumulation and serialization (PR #10285 by @hannesrudolph)
+    - Fix: Preserve reasoning_content in condense summary for DeepSeek-reasoner (PR #10292 by @hannesrudolph)
+    - Refactor Zai provider to merge environment_details into tool result instead of system message (PR #10289 by @hannesrudolph)
+    - Remove parallel_tool_calls parameter from litellm provider (PR #10274 by @roomote)
+    - Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph)
+    - Add 1M context window beta support for Claude Sonnet 4 on Vertex AI, enabling significantly larger context for complex tasks (PR #10209 by @hannesrudolph)
+    - Add native tool call defaults for OpenAI-compatible providers, expanding native function calling across more configurations (PR #10213 by @hannesrudolph)
+    - Enable native tool calls for Requesty provider (PR #10211 by @daniel-lxs)
+    - Improve API error handling and visibility with clearer error messages and better user feedback (PR #10204 by @brunobergher)
+    - Add downloadable error diagnostics from chat errors, making it easier to troubleshoot and report issues (PR #10188 by @brunobergher)
+    - Fix refresh models button not properly flushing the cache, ensuring model lists update correctly (#9682 by @tl-hbk, PR #9870 by @pdecat)
+    - Fix additionalProperties handling for strict mode compatibility, resolving schema validation issues with certain providers (PR #10210 by @daniel-lxs)
+    - Add native tool calling support for Claude models on Vertex AI, enabling more efficient and reliable tool interactions (PR #10197 by @hannesrudolph)
+    - Fix JSON Schema format value stripping for OpenAI compatibility, resolving issues with unsupported format values (PR #10198 by @daniel-lxs)
+    - Improve "no tools used" error handling with graceful retry mechanism for better reliability when tools fail to execute (PR #10196 by @hannesrudolph)
+    - Change default tool protocol from XML to native for improved reliability and performance (PR #10186 by @mrubens)
+    - Add native tool support for VS Code Language Model API providers (PR #10191 by @daniel-lxs)
+    - Lock task tool protocol for consistent task resumption, ensuring tasks resume with the same protocol they started with (PR #10192 by @daniel-lxs)
+    - Replace edit_file tool alias with actual edit_file tool for improved diff editing capabilities (PR #9983 by @hannesrudolph)
+    - Fix LiteLLM router models by merging default model info for native tool calling support (PR #10187 by @daniel-lxs)
+    - Fix: Add userAgentAppId to Bedrock embedder for code indexing (#10165 by @jackrein, PR #10166 by @roomote)
+    - Update OpenAI and Gemini tool preferences for improved model behavior (PR #10170 by @hannesrudolph)
+    - Add support for Claude Code Provider native tool calling, improving tool execution performance and reliability (PR #10077 by @hannesrudolph)
+    - Enable native tool calling by default for Z.ai models for better model compatibility (PR #10158 by @app/roomote)
+    - Enable native tools by default for OpenAI compatible provider to improve tool calling support (PR #10159 by @daniel-lxs)
+    - Fix: Normalize MCP tool schemas for Bedrock and OpenAI strict mode to ensure proper tool compatibility (PR #10148 by @daniel-lxs)
+    - Fix: Remove dots and colons from MCP tool names for Bedrock compatibility (PR #10152 by @daniel-lxs)
+    - Fix: Convert tool_result to XML text when native tools disabled for Bedrock (PR #10155 by @daniel-lxs)
+    - Fix: Support AWS GovCloud and China region ARNs in Bedrock provider for expanded regional support (PR #10157 by @app/roomote)
+    - Implement interleaved thinking mode for DeepSeek Reasoner, enabling streaming reasoning output (PR #9969 by @hannesrudolph)
+    - Fix: Preserve reasoning_content during tool call sequences in DeepSeek (PR #10141 by @hannesrudolph)
+    - Fix: Correct token counting for context truncation display (PR #9961 by @hannesrudolph)
+    - Fix: Normalize tool call IDs for cross-provider compatibility via OpenRouter, ensuring consistent handling across different AI providers (PR #10102 by @daniel-lxs)
+    - Fix: Add additionalProperties: false to nested MCP tool schemas, improving schema validation and preventing unexpected properties (PR #10109 by @daniel-lxs)
+    - Fix: Validate tool_result IDs in delegation resume flow, preventing errors when resuming delegated tasks (PR #10135 by @daniel-lxs)
+    - Feat: Add full error details to streaming failure dialog, providing more comprehensive information for debugging streaming issues (PR #10131 by @roomote)
+    - Implement incremental token-budgeted file reading for smarter, more efficient file content retrieval (PR #10052 by @jr)
+    - Enable native tools by default for multiple providers including OpenAI, Azure, Google, Vertex, and more (PR #10059 by @daniel-lxs)
+    - Enable native tools by default for Anthropic and add telemetry tracking for tool format usage (PR #10021 by @daniel-lxs)
+    - Fix: Prevent race condition from deleting wrong API messages during streaming (PR #10113 by @hannesrudolph)
+    - Fix: Prevent duplicate MCP tools error by deduplicating servers at source (PR #10096 by @daniel-lxs)
+    - Remove strict ARN validation for Bedrock custom ARN users allowing more flexibility (#10108 by @wisestmumbler, PR #10110 by @roomote)
+    - Add metadata to error details dialog for improved debugging (PR #10050 by @roomote)
+    - Remove description from Bedrock service tiers for cleaner UI (PR #10118 by @mrubens)
+    - Improve tool configuration for OpenAI models in OpenRouter (PR #10082 by @hannesrudolph)
+    - Capture more detailed provider-specific error information from OpenRouter for better debugging (PR #10073 by @jr)
+    - Add Amazon Nova 2 Lite model to Bedrock provider (#9802 by @Smartsheet-JB-Brown, PR #9830 by @roomote)
+    - Add AWS Bedrock service tier support (#9874 by @Smartsheet-JB-Brown, PR #9955 by @roomote)
+    - Remove auto-approve toggles for to-do and retry actions to simplify the approval workflow (PR #10062 by @hannesrudolph)
+    - Move isToolAllowedForMode out of shared directory for better code organization (PR #10089 by @cte)
+
+### Patch Changes
+
+- [#4950](https://github.com/Kilo-Org/kilocode/pull/4950) [`4b31180`](https://github.com/Kilo-Org/kilocode/commit/4b311806d571e115a6f6ab30d910e0bd39cc317b) Thanks [@markijbema](https://github.com/markijbema)! - Fix chat autocomplete to only show suggestions when textarea has focus, text hasn't changed, and clear suggestions on paste
+
+- [#4995](https://github.com/Kilo-Org/kilocode/pull/4995) [`95e9b6d`](https://github.com/Kilo-Org/kilocode/commit/95e9b6d234681d34f3903715de1ceba67e745516) Thanks [@kevinvandijk](https://github.com/kevinvandijk)! - fix: use correct api url for some endpoints
+
+- [#5008](https://github.com/Kilo-Org/kilocode/pull/5008) [`a86cd0c`](https://github.com/Kilo-Org/kilocode/commit/a86cd0c96a0aa0be112ccc5ee957ed3593caf2e8) Thanks [@markijbema](https://github.com/markijbema)! - Minor improvement to markdown autocomplete suggestions
+
+- [#4445](https://github.com/Kilo-Org/kilocode/pull/4445) [`91f9aa3`](https://github.com/Kilo-Org/kilocode/commit/91f9aa34d9f98e85c1500e204b8b576f82c9d606) Thanks [@chriscool](https://github.com/chriscool)! - fix: configure husky hooks for reliable execution
+
 ## 4.145.0
 
 ### Minor Changes

+ 135 - 11
CONTRIBUTING.md

@@ -1,16 +1,140 @@
-# Welcome to the Kilocode Project
+# Contributing to Kilo Code
 
-See [the Documentation for details on contributing](https://kilo.ai/docs/extending/contributing-to-kilo)
+First off, thanks for taking the time to contribute! ❤️
 
-## TL;DR
+All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for the team and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
 
-There are lots of ways to contribute to the project
+If you don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
 
-- **Code Contributions** Implement new features or fix bugs
-- **Documentation:** Improve existing docs or create new guides
-- **Custom Modes:** Create and share specialized modes
-- **Bug Reports:** Report issues you encounter
-- **Feature Requests:** Suggest new features or improvements
-- **Community Support:** Help other users in the community
+- Star the project
+- Post on X or Linkedin about Kilo Code `#kilocode
+- Mention the project at local meetups and tell your friends/colleagues
 
-The Kilocode Community is [on Discord](https://kilo.ai/discord)
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [I Have a Question](#i-have-a-question)
+- [I Want To Contribute](#i-want-to-contribute)
+    - [Code Contributors](#code-contributors)
+    - [Reporting Bugs](#reporting-bugs)
+    - [Custom Modes](#custom-modes)
+    - [Feature Requests](#feature-requests)
+    - [Improving The Documentation](#improving-the-documentation)
+    - [Improving The Design](#improving-the-design)
+    - [Publish a Blog Post or Case Study](#publish-a-blog-post-or-case-study)
+    - [Commit Messages](#commit-messages)
+- [Pull requests](#pull-requests)
+
+## Code of Conduct
+
+This project and everyone participating in it is governed by the [Code of Conduct](https://github.com/Kilo-Org/kilocode/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior
+to [[email protected]](mailto:[email protected]).
+
+## I Have a Question
+
+If you need clarification after reading this document, we encourage you to join our [discord](https://kilocode.ai/discord) workspace and join channels [kilo-dev-contributors](https://discord.com/channels/1349288496988160052/1391109167275577464) and [extensions-support channel](https://discord.com/channels/1349288496988160052/1349358641295265864).
+
+## I Want To Contribute
+
+### Code Contributors
+
+We’re excited that you’re interested in contributing code to Kilo Code! Before you start, please take a look at our [Development Guide](https://github.com/Kilo-Org/kilocode/blob/main/DEVELOPMENT.md), it includes setup instructions, build steps, and details on running tests locally.
+
+#### What to Expect
+
+- A GUI-based change with settings may involve 12–13 files, plus about 18 more for internationalization (i18n).
+
+- A new feature or major update might also require corresponding tests, translations, and settings configuration updates.
+
+Don’t let that scare you off, we just want you to have a realistic idea of what’s involved before diving in. You’ll learn a lot, and we’re here to help if you get stuck.
+
+#### Tips Before You Start
+
+- If your change affects any UI elements or Settings, expect it to touch multiple files and translations.
+
+- You can use our translation workflow to automate adding i18n strings instead of editing each language manually.
+
+Unsure if your contribution is “small” or “large”? Start a quick discussion in [kilo-dev-contributors](https://discord.com/channels/1349288496988160052/1391109167275577464) channel on discord or open an issue with good context, follow the commit and pull request guidelines below once you’re ready to open a PR.
+
+### Reporting Bugs
+
+Please use our issues templates that provide hints on what information we need to help you.
+
+> You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to [[email protected]](mailto:[email protected]).
+
+### Custom Modes
+
+Custom modes are a powerful way to extend Kilo Code's capabilities. To create and share a custom mode:
+
+- Follow the [Custom Modes documentation](https://kilo.ai/docs/customize/custom-modes) to create your mode.
+
+- Test your mode thoroughly
+
+- Share your mode with the community on [Reddit](https://www.reddit.com/r/kilocode/) or you can show off / start a discussion on [show-off-your-builds](https://discord.com/channels/1349288496988160052/1375399779760214037) or [workflows-and-integration](https://discord.com/channels/1349288496988160052/1420236932780130418) on discord.
+
+### Feature Requests
+
+Suggest feature requests in [Discussion](https://github.com/Kilo-Org/kilocode/discussions), only open an [Issue](https://github.com/Kilo-Org/kilocode/issues/new/choose) for reporting a bug or actual contributions. Don't open issues for questions or support, instead join our [Discord workspace](https://kilocode.ai/discord) and ask there.
+
+- Provide as much context as you can about what you're running into.
+
+### Improving The Documentation
+
+If you notice outdated information or areas that could be clarified, kindly start a discussion in the [general](https://discord.com/channels/1349288496988160052/1349288496988160055) channel on discord.
+Please note that the main [documentation](https://github.com/Kilo-Org/docs) repository has been archived, you can still view it for reference.
+
+### Improving The Design
+
+Design contributions are welcome! To ensure smooth collaboration, please use the Design Improvement Template when opening a design-related issue.
+This helps us gather the right context (such as wireframes, mockups, or visual references) and maintain a consistent design language across the project. Feedback and iterations are highly encouraged, design is always a shared process.
+
+### Publish a Blog Post or Case Study
+
+We love hearing how people use or extend Kilo Code in their own projects. If you’ve written about your experience, we’re happy to review it!
+Our blog and case study repository has been archived, you can still access it [here](https://github.com/Kilo-Org/docs/tree/main/blog-posts) for reference. To share your work, please start a discussion in the [general](https://discord.com/channels/1349288496988160052/1349288496988160055) channel on discord, summarizing your post or case study, with a link to the full content.
+
+### Commit Messages
+
+Writing clear and consistent commit messages helps maintainers understand the purpose of your changes. A good commit message should:
+
+- Be written in the present tense (e.g., Add new feature, not Added new feature)
+
+- Be short (50 characters or less for the summary line)
+
+- Include additional context in the body if needed
+
+- Reference related issue numbers (e.g., Fixes `#123)
+
+- Keep each commit focused on one logical change
+
+## Pull Requests
+
+When you’re ready to contribute your changes, follow these steps to create a clear and reviewable pull request:
+
+- Push your changes to your fork:
+
+    ```bash
+    git push origin your-branch-name
+    ```
+
+- Open a Pull Request against the main Kilo Code repository.
+
+- Select "Compare across forks" and choose your fork and branch.
+
+- Fill out the PR template with:
+
+- A clear description of your changes
+
+    - Any related issues (e.g., “Fixes `#123”)
+
+    - Testing steps or screenshots (if applicable)
+
+    - Notes for reviewers, if special attention is needed
+
+For more context, kindly read the official [contributing docs](https://kilo.ai/docs/contributing).
+
+Your contributions, big or small help make Kilo Code better for everyone!🫶
+
+## References
+
+This document was adapted from [https://contributing.md](https://contributing.md/)!

+ 1 - 1
DEVELOPMENT.md

@@ -10,7 +10,7 @@ Before you begin, choose one of the following development environment options:
 
 1. **Git** - For version control
 2. **Git LFS** - For large file storage (https://git-lfs.com/) - Required for handling GIF, MP4, and other binary assets
-3. **Node.js** (version [v20.19.2](https://github.com/Kilo-Org/kilocode/blob/main/.nvmrc) recommended)
+3. **Node.js** (version [v20.20.0](https://github.com/Kilo-Org/kilocode/blob/main/.nvmrc) recommended)
 4. **pnpm** - Package manager (https://pnpm.io/)
 5. **Visual Studio Code** - Our recommended IDE for development
 

+ 22 - 6
README.md

@@ -9,7 +9,7 @@
 # 🚀 Kilo
 
 > Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
-> #1 on OpenRouter. 1M+ Kilo Coders. 20T+ tokens processed
+> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed
 
 - ✨ Generate code from natural language
 - ✅ Checks its own work
@@ -24,6 +24,8 @@
   <img src="https://media.githubusercontent.com/media/Kilo-Org/kilocode/main/kilo.gif" width="100%" />
 </p>
 
+## Quick Links
+
 - [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download)
 - [Official Kilo.ai Home page](https://kilo.ai) (learn more)
 
@@ -31,12 +33,12 @@
 
 - **Code Generation:** Kilo can generate code using natural language.
 - **Inline Autocomplete:** Get intelligent code completions as you type, powered by AI.
-- **Task Automation:** Kilo can automate repetitive coding tasks.
-- **Automated Refactoring:** Kilo can refactor and improve existing code.
+- **Task Automation:** Kilo can automate repetitive coding tasks to save time..
+- **Automated Refactoring:** Kilo can refactor and improve existing code efficiently.
 - **MCP Server Marketplace**: Kilo can easily find, and use MCP servers to extend the agent capabilities.
 - **Multi Mode**: Plan with Architect, Code with Coder, and Debug with Debugger, and make your own custom modes.
 
-## How to get started with Kilo
+## Get Started
 
 1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
 2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly.
@@ -44,9 +46,23 @@
 
 [![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig)
 
-## Extension Development
+## Developer Setup
+
+If you want to contribute or modify the extension locally, see the [DEVELOPMENT.md](/DEVELOPMENT.md) file for build and setup instructions.
+
+## Contributing
+
+We welcome contributions from developers, writers, and enthusiasts!
+To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
+
+## Code of Conduct
+
+Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members.
+
+## License
 
-For details on building and developing the extension, see [DEVELOPMENT.md](/DEVELOPMENT.md)
+This project is licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
+You’re free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE).
 
 ## Contributing
 

+ 116 - 0
apps/cli/CHANGELOG.md

@@ -0,0 +1,116 @@
+# Changelog
+
+All notable changes to the `@roo-code/cli` package will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.0.45] - 2026-01-08
+
+### Changed
+
+- **Major Refactor**: Extracted ~1400 lines from [`App.tsx`](src/ui/App.tsx) into reusable hooks and utilities for better maintainability:
+
+    - [`useExtensionHost`](src/ui/hooks/useExtensionHost.ts) - Extension host connection and lifecycle management
+    - [`useMessageHandlers`](src/ui/hooks/useMessageHandlers.ts) - Message processing and state updates
+    - [`useTaskSubmit`](src/ui/hooks/useTaskSubmit.ts) - Task submission logic
+    - [`useGlobalInput`](src/ui/hooks/useGlobalInput.ts) - Global keyboard shortcut handling
+    - [`useFollowupCountdown`](src/ui/hooks/useFollowupCountdown.ts) - Auto-approval countdown logic
+    - [`useFocusManagement`](src/ui/hooks/useFocusManagement.ts) - Input focus state management
+    - [`usePickerHandlers`](src/ui/hooks/usePickerHandlers.ts) - Picker component event handling
+    - [`uiStateStore`](src/ui/stores/uiStateStore.ts) - UI-specific state (showExitHint, countdown, etc.)
+    - Tool data utilities ([`extractToolData`](src/ui/utils/toolDataUtils.ts), `formatToolOutput`, etc.)
+    - [`HorizontalLine`](src/ui/components/HorizontalLine.tsx) component
+
+- **Performance Optimizations**:
+
+    - Added RAF-style scroll throttling to reduce state updates
+    - Stabilized `useExtensionHost` hook return values with `useCallback`/`useMemo`
+    - Added streaming message debouncing to batch rapid partial updates
+    - Added shallow array equality checks to prevent unnecessary re-renders
+
+- Simplified [`ModeTool`](src/ui/components/tools/ModeTool.tsx) layout to horizontal with mode suffix
+- Simplified logging by removing verbose debug output and adding first/last partial message logging pattern
+- Updated Nerd Font icon codepoints in [`Icon`](src/ui/components/Icon.tsx) component
+
+### Added
+
+- `#` shortcut in help trigger for quick access to task history autocomplete
+
+### Fixed
+
+- Fixed a crash in message handling
+- Added protected file warning in tool approval prompts
+- Enabled `alwaysAllowWriteProtected` for non-interactive mode
+
+### Removed
+
+- Removed unused `renderLogger.ts` utility file
+
+### Tests
+
+- Updated extension-host tests to expect `[Tool Request]` format
+- Updated Icon tests to expect single-char Nerd Font icons
+
+## [0.0.44] - 2026-01-08
+
+### Added
+
+- **Tool Renderer Components**: Specialized renderers for displaying tool outputs with optimized formatting for each tool type. Each renderer provides a focused view of its data structure.
+
+    - [`FileReadTool`](src/ui/components/tools/FileReadTool.tsx) - Display file read operations with syntax highlighting
+    - [`FileWriteTool`](src/ui/components/tools/FileWriteTool.tsx) - Show file write/edit operations with diff views
+    - [`SearchTool`](src/ui/components/tools/SearchTool.tsx) - Render search results with context
+    - [`CommandTool`](src/ui/components/tools/CommandTool.tsx) - Display command execution with output
+    - [`BrowserTool`](src/ui/components/tools/BrowserTool.tsx) - Show browser automation actions
+    - [`ModeTool`](src/ui/components/tools/ModeTool.tsx) - Display mode switching operations
+    - [`CompletionTool`](src/ui/components/tools/CompletionTool.tsx) - Show task completion status
+    - [`GenericTool`](src/ui/components/tools/GenericTool.tsx) - Fallback renderer for other tools
+
+- **History Trigger**: New `#` trigger for task history autocomplete with fuzzy search support. Type `#` at the start of a line to browse and resume previous tasks.
+
+    - [`HistoryTrigger.tsx`](src/ui/components/autocomplete/triggers/HistoryTrigger.tsx) - Trigger implementation with fuzzy filtering
+    - Shows task status, mode, and relative timestamps
+    - Supports keyboard navigation for quick task selection
+
+- **Release Confirmation Prompt**: The release script now prompts for confirmation before creating a release.
+
+### Fixed
+
+- Task history picker selection and navigation issues
+- Mode switcher keyboard handling bug
+
+### Changed
+
+- Reorganized test files into `__tests__` directories for better project structure
+- Refactored utility modules into dedicated `utils/` directory
+
+## [0.0.43] - 2026-01-07
+
+### Added
+
+- **Toast Notification System**: New toast notifications for user feedback with support for info, success, warning, and error types. Toasts auto-dismiss after a configurable duration and are managed via Zustand store.
+
+    - New [`ToastDisplay`](src/ui/components/ToastDisplay.tsx) component for rendering toast messages
+    - New [`useToast`](src/ui/hooks/useToast.ts) hook for managing toast state and displaying notifications
+
+- **Global Input Sequences Registry**: Centralized system for handling keyboard shortcuts at the application level, preventing conflicts with input components.
+
+    - New [`globalInputSequences.ts`](src/ui/utils/globalInputSequences.ts) utility module
+    - Support for Kitty keyboard protocol (CSI u encoding) for better terminal compatibility
+    - Built-in sequences for `Ctrl+C` (exit) and `Ctrl+M` (mode cycling)
+
+- **Local Tarball Installation**: The install script now supports installing from a local tarball via the `ROO_LOCAL_TARBALL` environment variable, useful for offline installation or testing pre-release builds.
+
+### Changed
+
+- **MultilineTextInput**: Updated to respect global input sequences, preventing the component from consuming shortcuts meant for application-level handling.
+
+### Tests
+
+- Added comprehensive tests for the toast notification system
+- Added tests for global input sequence matching
+
+## [0.0.42] - 2025-01-07
+
+The cli is alive!

+ 262 - 0
apps/cli/README.md

@@ -0,0 +1,262 @@
+# @roo-code/cli
+
+Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode.
+
+## Overview
+
+This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment.
+
+## Installation
+
+### Quick Install (Recommended)
+
+Install the Roo Code CLI with a single command:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+```
+
+**Requirements:**
+
+- Node.js 20 or higher
+- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64)
+
+**Custom installation directory:**
+
+```bash
+ROO_INSTALL_DIR=/opt/roo-code ROO_BIN_DIR=/usr/local/bin curl -fsSL ... | sh
+```
+
+**Install a specific version:**
+
+```bash
+ROO_VERSION=0.1.0 curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+```
+
+### Updating
+
+Re-run the install script to update to the latest version:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+```
+
+### Uninstalling
+
+```bash
+rm -rf ~/.roo/cli ~/.local/bin/roo
+```
+
+### Development Installation
+
+For contributing or development:
+
+```bash
+# From the monorepo root.
+pnpm install
+
+# Build the main extension first.
+pnpm --filter roo-cline bundle
+
+# Build the cli.
+pnpm --filter @roo-code/cli build
+```
+
+## Usage
+
+### Interactive Mode (Default)
+
+By default, the CLI prompts for approval before executing actions:
+
+```bash
+export OPENROUTER_API_KEY=sk-or-v1-...
+
+roo ~/Documents/my-project -P "What is this project?"
+```
+
+You can also run without a prompt and enter it interactively in TUI mode:
+
+```bash
+roo ~/Documents/my-project
+```
+
+In interactive mode:
+
+- Tool executions prompt for yes/no approval
+- Commands prompt for yes/no approval
+- Followup questions show suggestions and wait for user input
+- Browser and MCP actions prompt for approval
+
+### Non-Interactive Mode (`-y`)
+
+For automation and scripts, use `-y` to auto-approve all actions:
+
+```bash
+roo ~/Documents/my-project -y -P "Refactor the utils.ts file"
+```
+
+In non-interactive mode:
+
+- Tool, command, browser, and MCP actions are auto-approved
+- Followup questions show a 60-second timeout, then auto-select the first suggestion
+- Typing any key cancels the timeout and allows manual input
+
+### Roo Code Cloud Authentication
+
+To use Roo Code Cloud features (like the provider proxy), you need to authenticate:
+
+```bash
+# Log in to Roo Code Cloud (opens browser)
+roo auth login
+
+# Check authentication status
+roo auth status
+
+# Log out
+roo auth logout
+```
+
+The `auth login` command:
+
+1. Opens your browser to authenticate with Roo Code Cloud
+2. Receives a secure token via localhost callback
+3. Stores the token in `~/.config/roo/credentials.json`
+
+Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when your token expires.
+
+**Authentication Flow:**
+
+```
+┌──────┐         ┌─────────┐         ┌───────────────┐
+│  CLI │         │ Browser │         │ Roo Code Cloud│
+└──┬───┘         └────┬────┘         └───────┬───────┘
+   │                  │                      │
+   │ Open auth URL    │                      │
+   │─────────────────>│                      │
+   │                  │                      │
+   │                  │ Authenticate         │
+   │                  │─────────────────────>│
+   │                  │                      │
+   │                  │<─────────────────────│
+   │                  │ Token via callback   │
+   │<─────────────────│                      │
+   │                  │                      │
+   │ Store token      │                      │
+   │                  │                      │
+```
+
+## Options
+
+| Option                            | Description                                                                             | Default                       |
+| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- |
+| `[workspace]`                     | Workspace path to operate in (positional argument)                                      | Current directory             |
+| `-P, --prompt <prompt>`           | The prompt/task to execute (optional in TUI mode)                                       | None                          |
+| `-e, --extension <path>`          | Path to the extension bundle directory                                                  | Auto-detected                 |
+| `-d, --debug`                     | Enable debug output (includes detailed debug information, prompts, paths, etc)          | `false`                       |
+| `-x, --exit-on-complete`          | Exit the process when task completes (useful for testing)                               | `false`                       |
+| `-y, --yes`                       | Non-interactive mode: auto-approve all actions                                          | `false`                       |
+| `-k, --api-key <key>`             | API key for the LLM provider                                                            | From env var                  |
+| `-p, --provider <provider>`       | API provider (anthropic, openai, openrouter, etc.)                                      | `openrouter`                  |
+| `-m, --model <model>`             | Model to use                                                                            | `anthropic/claude-sonnet-4.5` |
+| `-M, --mode <mode>`               | Mode to start in (code, architect, ask, debug, etc.)                                    | `code`                        |
+| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium`                      |
+| `--ephemeral`                     | Run without persisting state (uses temporary storage)                                   | `false`                       |
+| `--no-tui`                        | Disable TUI, use plain text output                                                      | `false`                       |
+
+## Auth Commands
+
+| Command           | Description                        |
+| ----------------- | ---------------------------------- |
+| `roo auth login`  | Authenticate with Roo Code Cloud   |
+| `roo auth logout` | Clear stored authentication token  |
+| `roo auth status` | Show current authentication status |
+
+## Environment Variables
+
+The CLI will look for API keys in environment variables if not provided via `--api-key`:
+
+| Provider      | Environment Variable |
+| ------------- | -------------------- |
+| anthropic     | `ANTHROPIC_API_KEY`  |
+| openai        | `OPENAI_API_KEY`     |
+| openrouter    | `OPENROUTER_API_KEY` |
+| google/gemini | `GOOGLE_API_KEY`     |
+| ...           | ...                  |
+
+**Authentication Environment Variables:**
+
+| Variable          | Description                                                          |
+| ----------------- | -------------------------------------------------------------------- |
+| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) |
+
+## Architecture
+
+```
+┌─────────────────┐
+│   CLI Entry     │
+│   (index.ts)    │
+└────────┬────────┘
+         │
+         ▼
+┌─────────────────┐
+│  ExtensionHost  │
+│  (extension-    │
+│   host.ts)      │
+└────────┬────────┘
+         │
+    ┌────┴────┐
+    │         │
+    ▼         ▼
+┌───────┐  ┌──────────┐
+│vscode │  │Extension │
+│-shim  │  │ Bundle   │
+└───────┘  └──────────┘
+```
+
+## How It Works
+
+1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost
+
+2. **ExtensionHost** (`extension-host.ts`):
+
+    - Creates a VSCode API mock using `@roo-code/vscode-shim`
+    - Intercepts `require('vscode')` to return the mock
+    - Loads and activates the extension bundle
+    - Manages bidirectional message flow
+
+3. **Message Flow**:
+    - CLI → Extension: `emit("webviewMessage", {...})`
+    - Extension → CLI: `emit("extensionWebviewMessage", {...})`
+
+## Development
+
+```bash
+# Watch mode for development
+pnpm dev
+
+# Run tests
+pnpm test
+
+# Type checking
+pnpm check-types
+
+# Linting
+pnpm lint
+```
+
+## Releasing
+
+To create a new release, execute the /cli-release slash command:
+
+```bash
+roo ~/Documents/Roo-Code -P "/cli-release" -y
+```
+
+The workflow will:
+
+1. Bump the version
+2. Update the CHANGELOG
+3. Build the extension and CLI
+4. Create a platform-specific tarball (for your current OS/architecture)
+5. Test the install script
+6. Create a GitHub release with the tarball attached

+ 355 - 0
apps/cli/docs/AGENT_LOOP.md

@@ -0,0 +1,355 @@
+# CLI Agent Loop
+
+This document explains how the Roo Code CLI detects and tracks the agent loop state.
+
+## Overview
+
+The CLI needs to know when the agent is:
+
+- **Running** (actively processing)
+- **Streaming** (receiving content from the API)
+- **Waiting for input** (needs user approval or answer)
+- **Idle** (task completed or failed)
+
+This is accomplished by analyzing the messages the extension sends to the client.
+
+## The Message Model
+
+All agent activity is communicated through **ClineMessages** - a stream of timestamped messages that represent everything the agent does.
+
+### Message Structure
+
+```typescript
+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?
+}
+```
+
+### Two Types of Messages
+
+| Type    | Purpose                                        | Blocks Agent? |
+| ------- | ---------------------------------------------- | ------------- |
+| **say** | Informational - agent is telling you something | No            |
+| **ask** | Interactive - agent needs something from you   | Usually yes   |
+
+## The Key Insight
+
+> **The agent loop stops whenever the last message is an `ask` type (with `partial: false`).**
+
+The specific `ask` value tells you exactly what the agent needs.
+
+## Ask Categories
+
+The CLI categorizes asks into four groups:
+
+### 1. Interactive Asks → `WAITING_FOR_INPUT` state
+
+These 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 |
+
+### 2. Idle Asks → `IDLE` state
+
+These 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                    |
+
+### 3. Resumable Asks → `RESUMABLE` state
+
+| Ask Type      | What It Means             | Response Options  |
+| ------------- | ------------------------- | ----------------- |
+| `resume_task` | Task paused mid-execution | Resume or abandon |
+
+### 4. Non-Blocking Asks → `RUNNING` state
+
+| Ask Type         | What It Means      | Response Options  |
+| ---------------- | ------------------ | ----------------- |
+| `command_output` | Command is running | Continue or abort |
+
+## Streaming Detection
+
+The agent is **streaming** when:
+
+1. **`partial: true`** on the last message, OR
+2. **An `api_req_started` message exists** with `cost: undefined` in its text field
+
+```typescript
+// 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
+}
+```
+
+## State Machine
+
+```
+                    ┌─────────────────┐
+                    │    NO_TASK      │  (no messages)
+                    └────────┬────────┘
+                             │ newTask
+                             ▼
+              ┌─────────────────────────────┐
+         ┌───▶│         RUNNING             │◀───┐
+         │    └──────────┬──────────────────┘    │
+         │               │                       │
+         │    ┌──────────┼──────────────┐        │
+         │    │          │              │        │
+         │    ▼          ▼              ▼        │
+         │ ┌──────┐  ┌─────────┐  ┌──────────┐   │
+         │ │STREAM│  │WAITING_ │  │   IDLE   │   │
+         │ │ ING  │  │FOR_INPUT│  │          │   │
+         │ └──┬───┘  └────┬────┘  └────┬─────┘   │
+         │    │           │            │         │
+         │    │ done      │ approved   │ newTask │
+         └────┴───────────┴────────────┘         │
+                                                 │
+         ┌──────────────┐                        │
+         │  RESUMABLE   │────────────────────────┘
+         └──────────────┘  resumed
+```
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        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)  │     │
+│  └────────────────┘  └────────────────┘  └────────────────┘     │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Component Responsibilities
+
+### ExtensionClient
+
+The **single source of truth** for agent state, including the current mode. It:
+
+- Receives all messages from the extension
+- Stores them in the `StateStore`
+- Tracks the current mode from state messages
+- Computes the current state via `detectAgentState()`
+- Emits events when state changes (including mode changes)
+
+```typescript
+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}`)
+})
+```
+
+### StateStore
+
+Holds the `clineMessages` array, computed state, and current mode:
+
+```typescript
+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")
+}
+```
+
+### MessageProcessor
+
+Handles incoming messages from the extension:
+
+- `"state"` messages → Update `clineMessages` array and track mode
+- `"messageUpdated"` messages → Update single message in array
+- Emits events for state transitions and mode changes
+
+### AskDispatcher
+
+Routes asks to appropriate handlers:
+
+- Uses type guards: `isIdleAsk()`, `isInteractiveAsk()`, etc.
+- Coordinates between `OutputManager` and `PromptManager`
+- In non-interactive mode (`-y` flag), auto-approves everything
+
+### OutputManager
+
+Handles all CLI output:
+
+- Streams partial content with delta computation
+- Tracks what's been displayed to avoid duplicates
+- Writes directly to `process.stdout` (bypasses quiet mode)
+
+### PromptManager
+
+Handles user input:
+
+- Yes/no prompts
+- Text input prompts
+- Timed prompts with auto-defaults
+
+## Response Messages
+
+When the agent is waiting, send these responses:
+
+```typescript
+// 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",
+})
+```
+
+## Type Guards
+
+The CLI uses type guards from `@roo-code/types` for categorization:
+
+```typescript
+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
+}
+```
+
+## Debug Logging
+
+Enable with `-d` flag. Logs go to `~/.roo/cli-debug.log`:
+
+```bash
+roo -d -y -P "Build something" --no-tui
+```
+
+View logs:
+
+```bash
+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 }
+```
+
+## Summary
+
+1. **Agent communicates via `ClineMessage` stream**
+2. **Last message determines state**
+3. **`ask` messages (non-partial) block the agent**
+4. **Ask category determines required action**
+5. **`partial: true` or `api_req_started` without cost = streaming**
+6. **`ExtensionClient` is the single source of truth**

+ 4 - 0
apps/cli/eslint.config.mjs

@@ -0,0 +1,4 @@
+import { config } from "@roo-code/config-eslint/base"
+
+/** @type {import("eslint").Linter.Config} */
+export default [...config]

+ 305 - 0
apps/cli/install.sh

@@ -0,0 +1,305 @@
+#!/bin/sh
+# Roo Code CLI Installer
+# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+#
+# Environment variables:
+#   ROO_INSTALL_DIR   - Installation directory (default: ~/.roo/cli)
+#   ROO_BIN_DIR       - Binary symlink directory (default: ~/.local/bin)
+#   ROO_VERSION       - Specific version to install (default: latest)
+#   ROO_LOCAL_TARBALL - Path to local tarball to install (skips download)
+
+set -e
+
+# Configuration
+INSTALL_DIR="${ROO_INSTALL_DIR:-$HOME/.roo/cli}"
+BIN_DIR="${ROO_BIN_DIR:-$HOME/.local/bin}"
+REPO="RooCodeInc/Roo-Code"
+MIN_NODE_VERSION=20
+
+# Color output (only if terminal supports it)
+if [ -t 1 ]; then
+    RED='\033[0;31m'
+    GREEN='\033[0;32m'
+    YELLOW='\033[1;33m'
+    BLUE='\033[0;34m'
+    BOLD='\033[1m'
+    NC='\033[0m'
+else
+    RED=''
+    GREEN=''
+    YELLOW=''
+    BLUE=''
+    BOLD=''
+    NC=''
+fi
+
+info() { printf "${GREEN}==>${NC} %s\n" "$1"; }
+warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; }
+error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; }
+
+# Check Node.js version
+check_node() {
+    if ! command -v node >/dev/null 2>&1; then
+        error "Node.js is not installed. Please install Node.js $MIN_NODE_VERSION or higher.
+
+Install Node.js:
+  - macOS: brew install node
+  - Linux: https://nodejs.org/en/download/package-manager
+  - Or use a version manager like fnm, nvm, or mise"
+    fi
+    
+    NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
+    if [ "$NODE_VERSION" -lt "$MIN_NODE_VERSION" ]; then
+        error "Node.js $MIN_NODE_VERSION+ required. Found: $(node -v)
+
+Please upgrade Node.js to version $MIN_NODE_VERSION or higher."
+    fi
+    
+    info "Found Node.js $(node -v)"
+}
+
+# Detect OS and architecture
+detect_platform() {
+    OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+    ARCH=$(uname -m)
+    
+    case "$OS" in
+        darwin) OS="darwin" ;;
+        linux) OS="linux" ;;
+        mingw*|msys*|cygwin*) 
+            error "Windows is not supported by this installer. Please use WSL or install manually."
+            ;;
+        *) error "Unsupported OS: $OS" ;;
+    esac
+    
+    case "$ARCH" in
+        x86_64|amd64) ARCH="x64" ;;
+        arm64|aarch64) ARCH="arm64" ;;
+        *) error "Unsupported architecture: $ARCH" ;;
+    esac
+    
+    PLATFORM="${OS}-${ARCH}"
+    info "Detected platform: $PLATFORM"
+}
+
+# Get latest release version or use specified version
+get_version() {
+    # Skip version fetch if using local tarball
+    if [ -n "$ROO_LOCAL_TARBALL" ]; then
+        VERSION="${ROO_VERSION:-local}"
+        info "Using local tarball (version: $VERSION)"
+        return
+    fi
+    
+    if [ -n "$ROO_VERSION" ]; then
+        VERSION="$ROO_VERSION"
+        info "Using specified version: $VERSION"
+        return
+    fi
+    
+    info "Fetching latest version..."
+    
+    # Try to get the latest cli release
+    RELEASES_JSON=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" 2>/dev/null) || {
+        error "Failed to fetch releases from GitHub. Check your internet connection."
+    }
+    
+    # Extract the latest cli-v* tag
+    VERSION=$(echo "$RELEASES_JSON" |
+              grep -o '"tag_name": "cli-v[^"]*"' |
+              head -1 |
+              sed 's/"tag_name": "cli-v//' |
+              sed 's/"//')
+    
+    if [ -z "$VERSION" ]; then
+        error "Could not find any CLI releases. The CLI may not have been released yet."
+    fi
+    
+    info "Latest version: $VERSION"
+}
+
+# Download and extract
+download_and_install() {
+    TARBALL="roo-cli-${PLATFORM}.tar.gz"
+    
+    # Create temp directory
+    TMP_DIR=$(mktemp -d)
+    trap "rm -rf $TMP_DIR" EXIT
+    
+    # Use local tarball if provided, otherwise download
+    if [ -n "$ROO_LOCAL_TARBALL" ]; then
+        if [ ! -f "$ROO_LOCAL_TARBALL" ]; then
+            error "Local tarball not found: $ROO_LOCAL_TARBALL"
+        fi
+        info "Using local tarball: $ROO_LOCAL_TARBALL"
+        cp "$ROO_LOCAL_TARBALL" "$TMP_DIR/$TARBALL"
+    else
+        URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
+        
+        info "Downloading from $URL..."
+        
+        # Download with progress indicator
+        HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
+            if [ "$HTTP_CODE" = "404" ]; then
+                error "Release not found for platform $PLATFORM version $VERSION.
+
+Available at: https://github.com/$REPO/releases"
+            fi
+            error "Download failed. HTTP code: $HTTP_CODE"
+        }
+
+        # Verify we got something
+        if [ ! -s "$TMP_DIR/$TARBALL" ]; then
+            error "Downloaded file is empty. Please try again."
+        fi
+    fi
+
+    # Remove old installation if exists
+    if [ -d "$INSTALL_DIR" ]; then
+        info "Removing previous installation..."
+        rm -rf "$INSTALL_DIR"
+    fi
+    
+    mkdir -p "$INSTALL_DIR"
+    
+    # Extract
+    info "Extracting to $INSTALL_DIR..."
+    tar -xzf "$TMP_DIR/$TARBALL" -C "$INSTALL_DIR" --strip-components=1 || {
+        error "Failed to extract tarball. The download may be corrupted."
+    }
+    
+    # Save ripgrep binary before npm install (npm install will overwrite node_modules)
+    RIPGREP_BIN=""
+    if [ -f "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" ]; then
+        RIPGREP_BIN="$TMP_DIR/rg"
+        cp "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg" "$RIPGREP_BIN"
+    fi
+    
+    # Install npm dependencies
+    info "Installing dependencies..."
+    cd "$INSTALL_DIR"
+    npm install --production --silent 2>/dev/null || {
+        warn "npm install failed, trying with --legacy-peer-deps..."
+        npm install --production --legacy-peer-deps --silent 2>/dev/null || {
+            error "Failed to install dependencies. Make sure npm is available."
+        }
+    }
+    cd - > /dev/null
+    
+    # Restore ripgrep binary after npm install
+    if [ -n "$RIPGREP_BIN" ] && [ -f "$RIPGREP_BIN" ]; then
+        mkdir -p "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin"
+        cp "$RIPGREP_BIN" "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg"
+        chmod +x "$INSTALL_DIR/node_modules/@vscode/ripgrep/bin/rg"
+    fi
+    
+    # Make executable
+    chmod +x "$INSTALL_DIR/bin/roo"
+    
+    # Also make ripgrep executable if it exists
+    if [ -f "$INSTALL_DIR/bin/rg" ]; then
+        chmod +x "$INSTALL_DIR/bin/rg"
+    fi
+}
+
+# Create symlink in bin directory
+setup_bin() {
+    mkdir -p "$BIN_DIR"
+    
+    # Remove old symlink if exists
+    if [ -L "$BIN_DIR/roo" ] || [ -f "$BIN_DIR/roo" ]; then
+        rm -f "$BIN_DIR/roo"
+    fi
+    
+    ln -sf "$INSTALL_DIR/bin/roo" "$BIN_DIR/roo"
+    info "Created symlink: $BIN_DIR/roo"
+}
+
+# Check if bin dir is in PATH and provide instructions
+check_path() {
+    case ":$PATH:" in
+        *":$BIN_DIR:"*) 
+            # Already in PATH
+            return 0
+            ;;
+    esac
+    
+    warn "$BIN_DIR is not in your PATH"
+    echo ""
+    echo "Add this line to your shell profile:"
+    echo ""
+    
+    # Detect shell and provide specific instructions
+    SHELL_NAME=$(basename "$SHELL")
+    case "$SHELL_NAME" in
+        zsh)
+            echo "  echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc"
+            echo "  source ~/.zshrc"
+            ;;
+        bash)
+            if [ -f "$HOME/.bashrc" ]; then
+                echo "  echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc"
+                echo "  source ~/.bashrc"
+            else
+                echo "  echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bash_profile"
+                echo "  source ~/.bash_profile"
+            fi
+            ;;
+        fish)
+            echo "  set -Ux fish_user_paths $BIN_DIR \$fish_user_paths"
+            ;;
+        *)
+            echo "  export PATH=\"$BIN_DIR:\$PATH\""
+            ;;
+    esac
+    echo ""
+}
+
+# Verify installation
+verify_install() {
+    if [ -x "$BIN_DIR/roo" ]; then
+        info "Verifying installation..."
+        # Just check if it runs without error
+        "$BIN_DIR/roo" --version >/dev/null 2>&1 || true
+    fi
+}
+
+# Print success message
+print_success() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Roo Code CLI installed successfully!${NC}\n"
+    echo ""
+    echo "  Installation: $INSTALL_DIR"
+    echo "  Binary: $BIN_DIR/roo"
+    echo "  Version: $VERSION"
+    echo ""
+    echo "  ${BOLD}Get started:${NC}"
+    echo "    roo --help"
+    echo ""
+    echo "  ${BOLD}Example:${NC}"
+    echo "    export OPENROUTER_API_KEY=sk-or-v1-..."
+    echo "    roo ~/my-project -P \"What is this project?\""
+    echo ""
+}
+
+# Main
+main() {
+    echo ""
+    printf "${BLUE}${BOLD}"
+    echo "  ╭─────────────────────────────────╮"
+    echo "  │     Roo Code CLI Installer      │"
+    echo "  ╰─────────────────────────────────╯"
+    printf "${NC}"
+    echo ""
+    
+    check_node
+    detect_platform
+    get_version
+    download_and_install
+    setup_bin
+    check_path
+    verify_install
+    print_success
+}
+
+main "$@"

+ 48 - 0
apps/cli/package.json

@@ -0,0 +1,48 @@
+{
+	"name": "@roo-code/cli",
+	"version": "0.0.45",
+	"description": "Roo Code CLI - Run the Roo Code agent from the command line",
+	"private": true,
+	"type": "module",
+	"main": "dist/index.js",
+	"bin": {
+		"roo": "dist/index.js"
+	},
+	"scripts": {
+		"format": "prettier --write 'src/**/*.ts'",
+		"lint": "eslint src --ext .ts --max-warnings=0",
+		"check-types": "tsc --noEmit",
+		"test": "vitest run",
+		"build": "tsup",
+		"dev": "tsup --watch",
+		"start": "ROO_SDK_BASE_URL=http://localhost:3001 ROO_AUTH_BASE_URL=http://localhost:3000 node dist/index.js",
+		"start:production": "node dist/index.js",
+		"release": "scripts/release.sh",
+		"clean": "rimraf dist .turbo"
+	},
+	"dependencies": {
+		"@inkjs/ui": "^2.0.0",
+		"@roo-code/core": "workspace:^",
+		"@roo-code/types": "workspace:^",
+		"@roo-code/vscode-shim": "workspace:^",
+		"@trpc/client": "^11.8.1",
+		"@vscode/ripgrep": "^1.15.9",
+		"commander": "^12.1.0",
+		"fuzzysort": "^3.1.0",
+		"ink": "^6.6.0",
+		"p-wait-for": "^5.0.2",
+		"react": "^19.1.0",
+		"superjson": "^2.2.6",
+		"zustand": "^5.0.0"
+	},
+	"devDependencies": {
+		"@roo-code/config-eslint": "workspace:^",
+		"@roo-code/config-typescript": "workspace:^",
+		"@types/node": "^24.1.0",
+		"@types/react": "^19.1.6",
+		"ink-testing-library": "^4.0.0",
+		"rimraf": "^6.0.1",
+		"tsup": "^8.4.0",
+		"vitest": "^3.2.3"
+	}
+}

+ 714 - 0
apps/cli/scripts/release.sh

@@ -0,0 +1,714 @@
+#!/bin/bash
+# Roo Code CLI Release Script
+#
+# Usage:
+#   ./apps/cli/scripts/release.sh [options] [version]
+#
+# Options:
+#   --dry-run    Run all steps except creating the GitHub release
+#   --local      Build for local testing only (no GitHub checks, no changelog prompts)
+#   --install    Install locally after building (only with --local)
+#   --skip-verify Skip end-to-end verification tests (faster local builds)
+#
+# Examples:
+#   ./apps/cli/scripts/release.sh           # Use version from package.json
+#   ./apps/cli/scripts/release.sh 0.1.0     # Specify version
+#   ./apps/cli/scripts/release.sh --dry-run # Test the release flow without pushing
+#   ./apps/cli/scripts/release.sh --dry-run 0.1.0  # Dry run with specific version
+#   ./apps/cli/scripts/release.sh --local   # Build for local testing
+#   ./apps/cli/scripts/release.sh --local --install  # Build and install locally
+#   ./apps/cli/scripts/release.sh --local --skip-verify  # Fast local build
+#
+# This script:
+# 1. Builds the extension and CLI
+# 2. Creates a tarball for the current platform
+# 3. Creates a GitHub release and uploads the tarball (unless --dry-run or --local)
+#
+# Prerequisites:
+#   - GitHub CLI (gh) installed and authenticated (not needed for --local)
+#   - pnpm installed
+#   - Run from the monorepo root directory
+
+set -e
+
+# Parse arguments
+DRY_RUN=false
+LOCAL_BUILD=false
+LOCAL_INSTALL=false
+SKIP_VERIFY=false
+VERSION_ARG=""
+
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        --dry-run)
+            DRY_RUN=true
+            shift
+            ;;
+        --local)
+            LOCAL_BUILD=true
+            shift
+            ;;
+        --install)
+            LOCAL_INSTALL=true
+            shift
+            ;;
+        --skip-verify)
+            SKIP_VERIFY=true
+            shift
+            ;;
+        -*)
+            echo "Unknown option: $1" >&2
+            exit 1
+            ;;
+        *)
+            VERSION_ARG="$1"
+            shift
+            ;;
+    esac
+done
+
+# Validate option combinations
+if [ "$LOCAL_INSTALL" = true ] && [ "$LOCAL_BUILD" = false ]; then
+    echo "Error: --install can only be used with --local" >&2
+    exit 1
+fi
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+BOLD='\033[1m'
+NC='\033[0m'
+
+info() { printf "${GREEN}==>${NC} %s\n" "$1"; }
+warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; }
+error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; }
+step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; }
+
+# Get script directory and repo root
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+CLI_DIR="$REPO_ROOT/apps/cli"
+
+# Detect current platform
+detect_platform() {
+    OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+    ARCH=$(uname -m)
+    
+    case "$OS" in
+        darwin) OS="darwin" ;;
+        linux) OS="linux" ;;
+        *) error "Unsupported OS: $OS" ;;
+    esac
+    
+    case "$ARCH" in
+        x86_64|amd64) ARCH="x64" ;;
+        arm64|aarch64) ARCH="arm64" ;;
+        *) error "Unsupported architecture: $ARCH" ;;
+    esac
+    
+    PLATFORM="${OS}-${ARCH}"
+}
+
+# Check prerequisites
+check_prerequisites() {
+    step "1/8" "Checking prerequisites..."
+    
+    # Skip GitHub CLI checks for local builds
+    if [ "$LOCAL_BUILD" = false ]; then
+        if ! command -v gh &> /dev/null; then
+            error "GitHub CLI (gh) is not installed. Install it with: brew install gh"
+        fi
+        
+        if ! gh auth status &> /dev/null; then
+            error "GitHub CLI is not authenticated. Run: gh auth login"
+        fi
+    fi
+    
+    if ! command -v pnpm &> /dev/null; then
+        error "pnpm is not installed."
+    fi
+    
+    if ! command -v node &> /dev/null; then
+        error "Node.js is not installed."
+    fi
+    
+    info "Prerequisites OK"
+}
+
+# Get version
+get_version() {
+    if [ -n "$VERSION_ARG" ]; then
+        VERSION="$VERSION_ARG"
+    else
+        VERSION=$(node -p "require('$CLI_DIR/package.json').version")
+    fi
+    
+    # For local builds, append a local suffix with git short hash
+    # This creates versions like: 0.1.0-local.abc1234
+    if [ "$LOCAL_BUILD" = true ]; then
+        GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
+        # Only append suffix if not already a local version
+        if ! echo "$VERSION" | grep -qE '\-local\.'; then
+            VERSION="${VERSION}-local.${GIT_SHORT_HASH}"
+        fi
+    fi
+    
+    # Validate semver format (allow -local.hash suffix)
+    if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
+        error "Invalid version format: $VERSION (expected semver like 0.1.0)"
+    fi
+    
+    TAG="cli-v$VERSION"
+    info "Version: $VERSION (tag: $TAG)"
+}
+
+# Extract changelog content for a specific version
+# Returns the content between the version header and the next version header (or EOF)
+get_changelog_content() {
+    CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md"
+    
+    if [ ! -f "$CHANGELOG_FILE" ]; then
+        warn "No CHANGELOG.md found at $CHANGELOG_FILE"
+        CHANGELOG_CONTENT=""
+        return
+    fi
+    
+    # Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats)
+    # Also handles "Unreleased" marker
+    VERSION_PATTERN="^\#\# \[${VERSION}\]"
+    
+    # Check if the version exists in the changelog
+    if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then
+        warn "No changelog entry found for version $VERSION"
+        # Skip prompts for local builds
+        if [ "$LOCAL_BUILD" = true ]; then
+            info "Skipping changelog prompt for local build"
+            CHANGELOG_CONTENT=""
+            return
+        fi
+        warn "Please add an entry to $CHANGELOG_FILE before releasing"
+        echo ""
+        echo "Expected format:"
+        echo "  ## [$VERSION] - $(date +%Y-%m-%d)"
+        echo "  "
+        echo "  ### Added"
+        echo "  - Your changes here"
+        echo ""
+        read -p "Continue without changelog content? [y/N] " -n 1 -r
+        echo
+        if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+            error "Aborted. Please add a changelog entry and try again."
+        fi
+        CHANGELOG_CONTENT=""
+        return
+    fi
+    
+    # Extract content between this version and the next version header (or EOF)
+    # Uses awk to capture everything between ## [VERSION] and the next ## [
+    # Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10)
+    CHANGELOG_CONTENT=$(awk -v version="$VERSION" '
+        BEGIN { found = 0; content = ""; target = "[" version "]" }
+        /^## \[/ {
+            if (found) { exit }
+            if (index($0, target) > 0) { found = 1; next }
+        }
+        found { content = content $0 "\n" }
+        END { print content }
+    ' "$CHANGELOG_FILE")
+    
+    # Trim leading/trailing whitespace
+    CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
+    
+    if [ -n "$CHANGELOG_CONTENT" ]; then
+        info "Found changelog content for version $VERSION"
+    else
+        warn "Changelog entry for $VERSION appears to be empty"
+    fi
+}
+
+# Build everything
+build() {
+    step "2/8" "Building extension bundle..."
+    cd "$REPO_ROOT"
+    pnpm bundle
+    
+    step "3/8" "Building CLI..."
+    pnpm --filter @roo-code/cli build
+    
+    info "Build complete"
+}
+
+# Create release tarball
+create_tarball() {
+    step "4/8" "Creating release tarball for $PLATFORM..."
+    
+    RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}"
+    TARBALL="roo-cli-${PLATFORM}.tar.gz"
+    
+    # Clean up any previous build
+    rm -rf "$RELEASE_DIR"
+    rm -f "$REPO_ROOT/$TARBALL"
+    
+    # Create directory structure
+    mkdir -p "$RELEASE_DIR/bin"
+    mkdir -p "$RELEASE_DIR/lib"
+    mkdir -p "$RELEASE_DIR/extension"
+    
+    # Copy CLI dist files
+    info "Copying CLI files..."
+    cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/"
+    
+    # Create package.json for npm install (runtime dependencies that can't be bundled)
+    info "Creating package.json..."
+    node -e "
+      const pkg = require('$CLI_DIR/package.json');
+      const newPkg = {
+        name: '@roo-code/cli',
+        version: '$VERSION',
+        type: 'module',
+        dependencies: {
+          '@inkjs/ui': pkg.dependencies['@inkjs/ui'],
+          '@trpc/client': pkg.dependencies['@trpc/client'],
+          'commander': pkg.dependencies.commander,
+          'fuzzysort': pkg.dependencies.fuzzysort,
+          'ink': pkg.dependencies.ink,
+          'react': pkg.dependencies.react,
+          'superjson': pkg.dependencies.superjson,
+          'zustand': pkg.dependencies.zustand
+        }
+      };
+      console.log(JSON.stringify(newPkg, null, 2));
+    " > "$RELEASE_DIR/package.json"
+    
+    # Copy extension bundle
+    info "Copying extension bundle..."
+    cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/"
+    
+    # Add package.json to extension directory to mark it as CommonJS
+    # This is necessary because the main package.json has "type": "module"
+    # but the extension bundle is CommonJS
+    echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json"
+    
+    # Find and copy ripgrep binary
+    # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg
+    # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there
+    info "Looking for ripgrep binary..."
+    RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1)
+    if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then
+        info "Found ripgrep at: $RIPGREP_PATH"
+        # Create the expected directory structure for the extension to find ripgrep
+        mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin"
+        cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/"
+        chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg"
+        # Also keep a copy in bin/ for direct access
+        mkdir -p "$RELEASE_DIR/bin"
+        cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/"
+        chmod +x "$RELEASE_DIR/bin/rg"
+    else
+        warn "ripgrep binary not found - users will need ripgrep installed"
+    fi
+    
+    # Create the wrapper script
+    info "Creating wrapper script..."
+    cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF'
+#!/usr/bin/env node
+
+import { fileURLToPath } from 'url';
+import { dirname, join } from 'path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+// Set environment variables for the CLI
+// ROO_CLI_ROOT is the installed CLI package root (where node_modules/@vscode/ripgrep is)
+process.env.ROO_CLI_ROOT = join(__dirname, '..');
+process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
+process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');
+
+// Import and run the actual CLI
+await import(join(__dirname, '..', 'lib', 'index.js'));
+WRAPPER_EOF
+
+    chmod +x "$RELEASE_DIR/bin/roo"
+    
+    # Create empty .env file to suppress dotenvx warnings
+    touch "$RELEASE_DIR/.env"
+    
+    # Create empty .env file to suppress dotenvx warnings
+    touch "$RELEASE_DIR/.env"
+    
+    # Create tarball
+    info "Creating tarball..."
+    cd "$REPO_ROOT"
+    tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
+    
+    # Clean up release directory
+    rm -rf "$RELEASE_DIR"
+    
+    # Show size
+    TARBALL_PATH="$REPO_ROOT/$TARBALL"
+    TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}')
+    info "Created: $TARBALL ($TARBALL_SIZE)"
+}
+
+# Verify local installation
+verify_local_install() {
+    if [ "$SKIP_VERIFY" = true ]; then
+        step "5/8" "Skipping verification (--skip-verify)"
+        return
+    fi
+    
+    step "5/8" "Verifying local installation..."
+    
+    VERIFY_DIR="$REPO_ROOT/.verify-release"
+    VERIFY_INSTALL_DIR="$VERIFY_DIR/cli"
+    VERIFY_BIN_DIR="$VERIFY_DIR/bin"
+    
+    # Clean up any previous verification directory
+    rm -rf "$VERIFY_DIR"
+    mkdir -p "$VERIFY_DIR"
+    
+    # Run the actual install script with the local tarball
+    info "Running install script with local tarball..."
+    TARBALL_PATH="$REPO_ROOT/$TARBALL"
+    
+    ROO_LOCAL_TARBALL="$TARBALL_PATH" \
+    ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \
+    ROO_BIN_DIR="$VERIFY_BIN_DIR" \
+    ROO_VERSION="$VERSION" \
+    "$CLI_DIR/install.sh" || {
+        echo ""
+        warn "Install script failed. Showing tarball contents:"
+        tar -tzf "$TARBALL_PATH" 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "Installation verification failed! The install script could not complete successfully."
+    }
+    
+    # Verify the CLI runs correctly with basic commands
+    info "Testing installed CLI..."
+    
+    # Test --help
+    if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then
+        echo ""
+        warn "CLI --help output:"
+        "$VERIFY_BIN_DIR/roo" --help 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "CLI --help check failed! The release tarball may have missing dependencies."
+    fi
+    info "CLI --help check passed"
+    
+    # Test --version
+    if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then
+        echo ""
+        warn "CLI --version output:"
+        "$VERIFY_BIN_DIR/roo" --version 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "CLI --version check failed! The release tarball may have missing dependencies."
+    fi
+    info "CLI --version check passed"
+    
+    # Run a simple end-to-end test to verify the CLI actually works
+    info "Running end-to-end verification test..."
+    
+    # Create a temporary workspace for the test
+    VERIFY_WORKSPACE="$VERIFY_DIR/workspace"
+    mkdir -p "$VERIFY_WORKSPACE"
+    
+    # Run the CLI with a simple prompt
+    # Use timeout to prevent hanging if something goes wrong
+    if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --exit-on-complete --prompt "1+1=?" "$VERIFY_WORKSPACE" > "$VERIFY_DIR/test-output.log" 2>&1; then
+        info "End-to-end test passed"
+    else
+        EXIT_CODE=$?
+        echo ""
+        warn "End-to-end test failed (exit code: $EXIT_CODE). Output:"
+        cat "$VERIFY_DIR/test-output.log" 2>&1 || true
+        echo ""
+        rm -rf "$VERIFY_DIR"
+        error "CLI end-to-end test failed! The CLI may be broken."
+    fi
+    
+    # Clean up verification directory
+    cd "$REPO_ROOT"
+    rm -rf "$VERIFY_DIR"
+    
+    info "Local verification passed!"
+}
+
+# Create checksum
+create_checksum() {
+    step "6/8" "Creating checksum..."
+    cd "$REPO_ROOT"
+    
+    if command -v sha256sum &> /dev/null; then
+        sha256sum "$TARBALL" > "${TARBALL}.sha256"
+    elif command -v shasum &> /dev/null; then
+        shasum -a 256 "$TARBALL" > "${TARBALL}.sha256"
+    else
+        warn "No sha256sum or shasum found, skipping checksum"
+        return
+    fi
+    
+    info "Checksum: $(cat "${TARBALL}.sha256")"
+}
+
+# Check if release already exists
+check_existing_release() {
+    step "7/8" "Checking for existing release..."
+    
+    if gh release view "$TAG" &> /dev/null; then
+        warn "Release $TAG already exists"
+        read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r
+        echo
+        if [[ $REPLY =~ ^[Yy]$ ]]; then
+            info "Deleting existing release..."
+            gh release delete "$TAG" --yes
+            # Also delete the tag if it exists
+            git tag -d "$TAG" 2>/dev/null || true
+            git push origin ":refs/tags/$TAG" 2>/dev/null || true
+        else
+            error "Aborted. Use a different version or delete the existing release manually."
+        fi
+    fi
+}
+
+# Create GitHub release
+create_release() {
+    step "8/8" "Creating GitHub release..."
+    cd "$REPO_ROOT"
+
+    # Get the current commit SHA for the release target
+    COMMIT_SHA=$(git rev-parse HEAD)
+    
+    # Verify the commit exists on GitHub before attempting to create the release
+    # This prevents the "Release.target_commitish is invalid" error
+    info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..."
+    git fetch origin 2>/dev/null || true
+    if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then
+        warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub"
+        echo ""
+        echo "The release script needs to create a release at your current commit,"
+        echo "but this commit hasn't been pushed to GitHub yet."
+        echo ""
+        read -p "Push current branch to origin now? [Y/n] " -n 1 -r
+        echo
+        if [[ ! $REPLY =~ ^[Nn]$ ]]; then
+            info "Pushing to origin..."
+            git push origin HEAD || error "Failed to push to origin. Please push manually and try again."
+        else
+            error "Aborted. Please push your commits to GitHub and try again."
+        fi
+    fi
+    info "Commit verified on GitHub"
+
+    # Build the What's New section from changelog content
+    WHATS_NEW_SECTION=""
+    if [ -n "$CHANGELOG_CONTENT" ]; then
+        WHATS_NEW_SECTION="## What's New
+
+$CHANGELOG_CONTENT
+
+"
+    fi
+    
+    RELEASE_NOTES=$(cat << EOF
+${WHATS_NEW_SECTION}## Installation
+
+\`\`\`bash
+curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+\`\`\`
+
+Or install a specific version:
+\`\`\`bash
+ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
+\`\`\`
+
+## Requirements
+
+- Node.js 20 or higher
+- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64)
+
+## Usage
+
+\`\`\`bash
+# Set your API key
+export OPENROUTER_API_KEY=sk-or-v1-...
+
+# Run a task
+roo "What is this project?" ~/my-project
+
+# See all options
+roo --help
+\`\`\`
+
+## Platform Support
+
+This release includes:
+- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m)
+
+> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue.
+
+## Checksum
+
+\`\`\`
+$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A")
+\`\`\`
+EOF
+)
+
+    info "Creating release at commit: ${COMMIT_SHA:0:8}"
+    
+    # Create release (gh will create the tag automatically)
+    info "Creating release..."
+    RELEASE_FILES="$TARBALL"
+    if [ -f "${TARBALL}.sha256" ]; then
+        RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256"
+    fi
+    
+    gh release create "$TAG" \
+        --title "Roo Code CLI v$VERSION" \
+        --notes "$RELEASE_NOTES" \
+        --prerelease \
+        --target "$COMMIT_SHA" \
+        $RELEASE_FILES
+    
+    info "Release created!"
+}
+
+# Cleanup
+cleanup() {
+    info "Cleaning up..."
+    cd "$REPO_ROOT"
+    rm -f "$TARBALL" "${TARBALL}.sha256"
+}
+
+# Print summary
+print_summary() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n"
+    echo ""
+    echo "  Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG"
+    echo ""
+    echo "  Install with:"
+    echo "    curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh"
+    echo ""
+}
+
+# Print dry-run summary
+print_dry_run_summary() {
+    echo ""
+    printf "${YELLOW}${BOLD}✓ Dry run complete for v$VERSION${NC}\n"
+    echo ""
+    echo "  The following artifacts were created:"
+    echo "    - $TARBALL"
+    if [ -f "${TARBALL}.sha256" ]; then
+        echo "    - ${TARBALL}.sha256"
+    fi
+    echo ""
+    echo "  To complete the release, run without --dry-run:"
+    echo "    ./apps/cli/scripts/release.sh $VERSION"
+    echo ""
+    echo "  Or manually upload the tarball to a new GitHub release."
+    echo ""
+}
+
+# Print local build summary
+print_local_summary() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n"
+    echo ""
+    echo "  Tarball: $REPO_ROOT/$TARBALL"
+    if [ -f "${TARBALL}.sha256" ]; then
+        echo "  Checksum: $REPO_ROOT/${TARBALL}.sha256"
+    fi
+    echo ""
+    echo "  To install manually:"
+    echo "    ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh"
+    echo ""
+    echo "  Or re-run with --install to install automatically:"
+    echo "    ./apps/cli/scripts/release.sh --local --install"
+    echo ""
+}
+
+# Install locally using the install script
+install_local() {
+    step "7/8" "Installing locally..."
+    
+    TARBALL_PATH="$REPO_ROOT/$TARBALL"
+    
+    ROO_LOCAL_TARBALL="$TARBALL_PATH" \
+    ROO_VERSION="$VERSION" \
+    "$CLI_DIR/install.sh" || {
+        error "Local installation failed!"
+    }
+    
+    info "Local installation complete!"
+}
+
+# Print local install summary
+print_local_install_summary() {
+    echo ""
+    printf "${GREEN}${BOLD}✓ Local build installed for v$VERSION${NC}\n"
+    echo ""
+    echo "  Tarball: $REPO_ROOT/$TARBALL"
+    echo "  Installed to: ~/.roo/cli"
+    echo "  Binary: ~/.local/bin/roo"
+    echo ""
+    echo "  Test it out:"
+    echo "    roo --version"
+    echo "    roo --help"
+    echo ""
+}
+
+# Main
+main() {
+    echo ""
+    printf "${BLUE}${BOLD}"
+    echo "  ╭─────────────────────────────────╮"
+    echo "  │   Roo Code CLI Release Script   │"
+    echo "  ╰─────────────────────────────────╯"
+    printf "${NC}"
+    
+    if [ "$DRY_RUN" = true ]; then
+        printf "${YELLOW}         (DRY RUN MODE)${NC}\n"
+    elif [ "$LOCAL_BUILD" = true ]; then
+        printf "${YELLOW}         (LOCAL BUILD MODE)${NC}\n"
+    fi
+    echo ""
+    
+    detect_platform
+    check_prerequisites
+    get_version
+    get_changelog_content
+    build
+    create_tarball
+    verify_local_install
+    create_checksum
+    
+    if [ "$LOCAL_BUILD" = true ]; then
+        step "7/8" "Skipping GitHub checks (local build)"
+        if [ "$LOCAL_INSTALL" = true ]; then
+            install_local
+            print_local_install_summary
+        else
+            step "8/8" "Skipping installation (use --install to auto-install)"
+            print_local_summary
+        fi
+    elif [ "$DRY_RUN" = true ]; then
+        step "7/8" "Skipping existing release check (dry run)"
+        step "8/8" "Skipping GitHub release creation (dry run)"
+        print_dry_run_summary
+    else
+        check_existing_release
+        create_release
+        cleanup
+        print_summary
+    fi
+}
+
+main

+ 126 - 0
apps/cli/src/__tests__/index.test.ts

@@ -0,0 +1,126 @@
+/**
+ * Integration tests for CLI
+ *
+ * These tests require:
+ * 1. RUN_CLI_INTEGRATION_TESTS=true environment variable (opt-in)
+ * 2. A valid OPENROUTER_API_KEY environment variable
+ * 3. A built CLI at apps/cli/dist (will auto-build if missing)
+ * 4. A built extension at src/dist (will auto-build if missing)
+ *
+ * Run with: RUN_CLI_INTEGRATION_TESTS=true OPENROUTER_API_KEY=sk-or-v1-... pnpm test
+ */
+
+// pnpm --filter @roo-code/cli test src/__tests__/index.test.ts
+
+import path from "path"
+import fs from "fs"
+import { execSync, spawn, type ChildProcess } from "child_process"
+import { fileURLToPath } from "url"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+const RUN_INTEGRATION_TESTS = process.env.RUN_CLI_INTEGRATION_TESTS === "true"
+const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
+const hasApiKey = !!OPENROUTER_API_KEY
+
+function findCliRoot(): string {
+	// From apps/cli/src/__tests__, go up to apps/cli.
+	return path.resolve(__dirname, "../..")
+}
+
+function findMonorepoRoot(): string {
+	// From apps/cli/src/__tests__, go up to monorepo root.
+	return path.resolve(__dirname, "../../../..")
+}
+
+function isCliBuilt(): boolean {
+	return fs.existsSync(path.join(findCliRoot(), "dist", "index.js"))
+}
+
+function isExtensionBuilt(): boolean {
+	const monorepoRoot = findMonorepoRoot()
+	const extensionPath = path.join(monorepoRoot, "src/dist")
+	return fs.existsSync(path.join(extensionPath, "extension.js"))
+}
+
+function buildCliIfNeeded(): void {
+	if (!isCliBuilt()) {
+		execSync("pnpm build", { cwd: findCliRoot(), stdio: "inherit" })
+		console.log("CLI build complete.")
+	}
+}
+
+function buildExtensionIfNeeded(): void {
+	if (!isExtensionBuilt()) {
+		execSync("pnpm --filter roo-cline bundle", { cwd: findMonorepoRoot(), stdio: "inherit" })
+		console.log("Extension build complete.")
+	}
+}
+
+function runCli(
+	args: string[],
+	options: { timeout?: number } = {},
+): Promise<{ stdout: string; stderr: string; exitCode: number }> {
+	return new Promise((resolve) => {
+		const timeout = options.timeout ?? 60000
+
+		let stdout = ""
+		let stderr = ""
+		let timedOut = false
+
+		const proc: ChildProcess = spawn("pnpm", ["start", ...args], {
+			cwd: findCliRoot(),
+			env: { ...process.env, OPENROUTER_API_KEY, NO_COLOR: "1", FORCE_COLOR: "0" },
+			stdio: ["pipe", "pipe", "pipe"],
+		})
+
+		const timeoutId = setTimeout(() => {
+			timedOut = true
+			proc.kill("SIGTERM")
+		}, timeout)
+
+		proc.stdout?.on("data", (data: Buffer) => {
+			stdout += data.toString()
+		})
+
+		proc.stderr?.on("data", (data: Buffer) => {
+			stderr += data.toString()
+		})
+
+		proc.on("close", (code: number | null) => {
+			clearTimeout(timeoutId)
+			resolve({ stdout, stderr, exitCode: timedOut ? -1 : (code ?? 1) })
+		})
+
+		proc.on("error", (error: Error) => {
+			clearTimeout(timeoutId)
+			stderr += error.message
+			resolve({ stdout, stderr, exitCode: 1 })
+		})
+	})
+}
+
+describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey)("CLI Integration Tests", () => {
+	beforeAll(() => {
+		buildExtensionIfNeeded()
+		buildCliIfNeeded()
+	})
+
+	it("should complete end-to-end task execution via CLI", async () => {
+		const result = await runCli(
+			["--no-tui", "-m", "anthropic/claude-sonnet-4.5", "-M", "ask", "-r", "disabled", "-P", "1+1=?"],
+			{ timeout: 30_000 },
+		)
+
+		console.log("CLI stdout:", result.stdout)
+
+		if (result.stderr) {
+			console.log("CLI stderr:", result.stderr)
+		}
+
+		expect(result.exitCode).toBe(0)
+		expect(result.stdout).toContain("2")
+		expect(result.stdout).toContain("[task complete]")
+	}, 30_000)
+})

+ 858 - 0
apps/cli/src/agent/__tests__/extension-client.test.ts

@@ -0,0 +1,858 @@
+import {
+	type ClineMessage,
+	type ExtensionMessage,
+	isIdleAsk,
+	isResumableAsk,
+	isInteractiveAsk,
+	isNonBlockingAsk,
+} from "@roo-code/types"
+
+import { AgentLoopState, detectAgentState } from "../agent-state.js"
+import { createMockClient } from "../extension-client.js"
+
+function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
+	return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides }
+}
+
+function createStateMessage(messages: ClineMessage[], mode?: string): ExtensionMessage {
+	return { type: "state", state: { clineMessages: messages, mode } } as ExtensionMessage
+}
+
+describe("detectAgentState", () => {
+	describe("NO_TASK state", () => {
+		it("should return NO_TASK for empty messages array", () => {
+			const state = detectAgentState([])
+			expect(state.state).toBe(AgentLoopState.NO_TASK)
+			expect(state.isWaitingForInput).toBe(false)
+			expect(state.isRunning).toBe(false)
+		})
+
+		it("should return NO_TASK for undefined messages", () => {
+			const state = detectAgentState(undefined as unknown as ClineMessage[])
+			expect(state.state).toBe(AgentLoopState.NO_TASK)
+		})
+	})
+
+	describe("STREAMING state", () => {
+		it("should detect streaming when partial is true", () => {
+			const messages = [createMessage({ type: "ask", ask: "tool", partial: true })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+			expect(state.isWaitingForInput).toBe(false)
+		})
+
+		it("should detect streaming when api_req_started has no cost", () => {
+			const messages = [
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ tokensIn: 100 }), // No cost field.
+				}),
+			]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+
+		it("should NOT be streaming when api_req_started has cost", () => {
+			const messages = [
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001, tokensIn: 100 }),
+				}),
+			]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+	})
+
+	describe("WAITING_FOR_INPUT state", () => {
+		it("should detect waiting for tool approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.isWaitingForInput).toBe(true)
+			expect(state.currentAsk).toBe("tool")
+			expect(state.requiredAction).toBe("approve")
+		})
+
+		it("should detect waiting for command approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "command", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("command")
+			expect(state.requiredAction).toBe("approve")
+		})
+
+		it("should detect waiting for followup answer", () => {
+			const messages = [createMessage({ type: "ask", ask: "followup", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("followup")
+			expect(state.requiredAction).toBe("answer")
+		})
+
+		it("should detect waiting for browser_action_launch approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.requiredAction).toBe("approve")
+		})
+
+		it("should detect waiting for use_mcp_server approval", () => {
+			const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.requiredAction).toBe("approve")
+		})
+	})
+
+	describe("IDLE state", () => {
+		it("should detect completion_result as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "completion_result", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.isWaitingForInput).toBe(true)
+			expect(state.requiredAction).toBe("start_task")
+		})
+
+		it("should detect api_req_failed as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "api_req_failed", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("retry_or_new_task")
+		})
+
+		it("should detect mistake_limit_reached as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("proceed_or_new_task")
+		})
+
+		it("should detect auto_approval_max_req_reached as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "auto_approval_max_req_reached", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("start_new_task")
+		})
+
+		it("should detect resume_completed_task as idle", () => {
+			const messages = [createMessage({ type: "ask", ask: "resume_completed_task", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.requiredAction).toBe("start_new_task")
+		})
+	})
+
+	describe("RESUMABLE state", () => {
+		it("should detect resume_task as resumable", () => {
+			const messages = [createMessage({ type: "ask", ask: "resume_task", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RESUMABLE)
+			expect(state.isWaitingForInput).toBe(true)
+			expect(state.requiredAction).toBe("resume_or_abandon")
+		})
+	})
+
+	describe("RUNNING state", () => {
+		it("should detect running for say messages", () => {
+			const messages = [
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "Working on it..." }),
+			]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isRunning).toBe(true)
+			expect(state.isWaitingForInput).toBe(false)
+		})
+
+		it("should detect running for command_output (non-blocking)", () => {
+			const messages = [createMessage({ type: "ask", ask: "command_output", partial: false })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.requiredAction).toBe("continue_or_abort")
+		})
+	})
+})
+
+describe("Type Guards", () => {
+	describe("isIdleAsk", () => {
+		it("should return true for idle asks", () => {
+			expect(isIdleAsk("completion_result")).toBe(true)
+			expect(isIdleAsk("api_req_failed")).toBe(true)
+			expect(isIdleAsk("mistake_limit_reached")).toBe(true)
+			expect(isIdleAsk("auto_approval_max_req_reached")).toBe(true)
+			expect(isIdleAsk("resume_completed_task")).toBe(true)
+		})
+
+		it("should return false for non-idle asks", () => {
+			expect(isIdleAsk("tool")).toBe(false)
+			expect(isIdleAsk("followup")).toBe(false)
+			expect(isIdleAsk("resume_task")).toBe(false)
+		})
+	})
+
+	describe("isInteractiveAsk", () => {
+		it("should return true for interactive asks", () => {
+			expect(isInteractiveAsk("tool")).toBe(true)
+			expect(isInteractiveAsk("command")).toBe(true)
+			expect(isInteractiveAsk("followup")).toBe(true)
+			expect(isInteractiveAsk("browser_action_launch")).toBe(true)
+			expect(isInteractiveAsk("use_mcp_server")).toBe(true)
+		})
+
+		it("should return false for non-interactive asks", () => {
+			expect(isInteractiveAsk("completion_result")).toBe(false)
+			expect(isInteractiveAsk("command_output")).toBe(false)
+		})
+	})
+
+	describe("isResumableAsk", () => {
+		it("should return true for resumable asks", () => {
+			expect(isResumableAsk("resume_task")).toBe(true)
+		})
+
+		it("should return false for non-resumable asks", () => {
+			expect(isResumableAsk("completion_result")).toBe(false)
+			expect(isResumableAsk("tool")).toBe(false)
+		})
+	})
+
+	describe("isNonBlockingAsk", () => {
+		it("should return true for non-blocking asks", () => {
+			expect(isNonBlockingAsk("command_output")).toBe(true)
+		})
+
+		it("should return false for blocking asks", () => {
+			expect(isNonBlockingAsk("tool")).toBe(false)
+			expect(isNonBlockingAsk("followup")).toBe(false)
+		})
+	})
+})
+
+describe("ExtensionClient", () => {
+	describe("State queries", () => {
+		it("should return NO_TASK when not initialized", () => {
+			const { client } = createMockClient()
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+			expect(client.isInitialized()).toBe(false)
+		})
+
+		it("should update state when receiving messages", () => {
+			const { client } = createMockClient()
+
+			const message = createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])
+
+			client.handleMessage(message)
+
+			expect(client.isInitialized()).toBe(true)
+			expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(client.isWaitingForInput()).toBe(true)
+			expect(client.getCurrentAsk()).toBe("tool")
+		})
+	})
+
+	describe("Event emission", () => {
+		it("should emit stateChange events", () => {
+			const { client } = createMockClient()
+			const stateChanges: AgentLoopState[] = []
+
+			client.onStateChange((event) => {
+				stateChanges.push(event.currentState.state)
+			})
+
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
+
+			expect(stateChanges).toContain(AgentLoopState.WAITING_FOR_INPUT)
+		})
+
+		it("should emit waitingForInput events", () => {
+			const { client } = createMockClient()
+			const waitingEvents: string[] = []
+
+			client.onWaitingForInput((event) => {
+				waitingEvents.push(event.ask)
+			})
+
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "followup", partial: false })]))
+
+			expect(waitingEvents).toContain("followup")
+		})
+
+		it("should allow unsubscribing from events", () => {
+			const { client } = createMockClient()
+			let callCount = 0
+
+			const unsubscribe = client.onStateChange(() => {
+				callCount++
+			})
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
+			expect(callCount).toBe(1)
+
+			unsubscribe()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
+			expect(callCount).toBe(1) // Should not increase.
+		})
+
+		it("should emit modeChanged events", () => {
+			const { client } = createMockClient()
+			const modeChanges: { previousMode: string | undefined; currentMode: string }[] = []
+
+			client.onModeChanged((event) => {
+				modeChanges.push(event)
+			})
+
+			// Set initial mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+
+			expect(modeChanges).toHaveLength(1)
+			expect(modeChanges[0]).toEqual({ previousMode: undefined, currentMode: "code" })
+
+			// Change mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect"))
+
+			expect(modeChanges).toHaveLength(2)
+			expect(modeChanges[1]).toEqual({ previousMode: "code", currentMode: "architect" })
+		})
+
+		it("should not emit modeChanged when mode stays the same", () => {
+			const { client } = createMockClient()
+			let modeChangeCount = 0
+
+			client.onModeChanged(() => {
+				modeChangeCount++
+			})
+
+			// Set initial mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(modeChangeCount).toBe(1)
+
+			// Same mode - should not emit
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "code"))
+			expect(modeChangeCount).toBe(1)
+		})
+	})
+
+	describe("Response methods", () => {
+		it("should send approve response", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.approve()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "askResponse",
+				askResponse: "yesButtonClicked",
+				text: undefined,
+				images: undefined,
+			})
+		})
+
+		it("should send reject response", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.reject()
+
+			expect(sentMessages).toHaveLength(1)
+			const msg = sentMessages[0]
+			expect(msg).toBeDefined()
+			expect(msg?.askResponse).toBe("noButtonClicked")
+		})
+
+		it("should send text response", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.respond("My answer", ["image-data"])
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "askResponse",
+				askResponse: "messageResponse",
+				text: "My answer",
+				images: ["image-data"],
+			})
+		})
+
+		it("should send newTask message", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.newTask("Build a web app")
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "newTask",
+				text: "Build a web app",
+				images: undefined,
+			})
+		})
+
+		it("should send clearTask message", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.clearTask()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "clearTask",
+			})
+		})
+
+		it("should send cancelTask message", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.cancelTask()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "cancelTask",
+			})
+		})
+
+		it("should send terminal continue operation", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.continueTerminal()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "terminalOperation",
+				terminalOperation: "continue",
+			})
+		})
+
+		it("should send terminal abort operation", () => {
+			const { client, sentMessages } = createMockClient()
+
+			client.abortTerminal()
+
+			expect(sentMessages).toHaveLength(1)
+			expect(sentMessages[0]).toEqual({
+				type: "terminalOperation",
+				terminalOperation: "abort",
+			})
+		})
+	})
+
+	describe("Message handling", () => {
+		it("should handle JSON string messages", () => {
+			const { client } = createMockClient()
+
+			const message = JSON.stringify(
+				createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
+			)
+
+			client.handleMessage(message)
+
+			expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
+		})
+
+		it("should ignore invalid JSON", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage("not valid json")
+
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+
+		it("should handle messageUpdated messages", () => {
+			const { client } = createMockClient()
+
+			// First, set initial state.
+			client.handleMessage(
+				createStateMessage([createMessage({ ts: 123, type: "ask", ask: "tool", partial: true })]),
+			)
+
+			expect(client.isStreaming()).toBe(true)
+
+			// Now update the message.
+			client.handleMessage({
+				type: "messageUpdated",
+				clineMessage: createMessage({ ts: 123, type: "ask", ask: "tool", partial: false }),
+			})
+
+			expect(client.isStreaming()).toBe(false)
+			expect(client.isWaitingForInput()).toBe(true)
+		})
+	})
+
+	describe("Reset functionality", () => {
+		it("should reset state", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
+
+			expect(client.isInitialized()).toBe(true)
+			expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
+
+			client.reset()
+
+			expect(client.isInitialized()).toBe(false)
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+
+		it("should reset mode on reset", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(client.getCurrentMode()).toBe("code")
+
+			client.reset()
+
+			expect(client.getCurrentMode()).toBeUndefined()
+		})
+	})
+
+	describe("Mode tracking", () => {
+		it("should return undefined mode when not initialized", () => {
+			const { client } = createMockClient()
+			expect(client.getCurrentMode()).toBeUndefined()
+		})
+
+		it("should track mode from state messages", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+
+			expect(client.getCurrentMode()).toBe("code")
+		})
+
+		it("should update mode when it changes", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(client.getCurrentMode()).toBe("code")
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })], "architect"))
+			expect(client.getCurrentMode()).toBe("architect")
+		})
+
+		it("should preserve mode when state message has no mode", () => {
+			const { client } = createMockClient()
+
+			// Set initial mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "code"))
+			expect(client.getCurrentMode()).toBe("code")
+
+			// State update without mode - should preserve existing mode
+			client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
+			expect(client.getCurrentMode()).toBe("code")
+		})
+
+		it("should preserve mode when task is cleared", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })], "architect"))
+			expect(client.getCurrentMode()).toBe("architect")
+
+			client.clearTask()
+			// Mode should be preserved after clear
+			expect(client.getCurrentMode()).toBe("architect")
+		})
+	})
+})
+
+describe("Integration", () => {
+	it("should handle a complete task flow", () => {
+		const { client } = createMockClient()
+		const states: AgentLoopState[] = []
+
+		client.onStateChange((event) => {
+			states.push(event.currentState.state)
+		})
+
+		// 1. Task starts, API request begins.
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({}), // No cost = streaming.
+				}),
+			]),
+		)
+		expect(client.isStreaming()).toBe(true)
+
+		// 2. API request completes.
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+			]),
+		)
+		expect(client.isStreaming()).toBe(false)
+		expect(client.isRunning()).toBe(true)
+
+		// 3. Tool ask (partial).
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+				createMessage({ type: "ask", ask: "tool", partial: true }),
+			]),
+		)
+		expect(client.isStreaming()).toBe(true)
+
+		// 4. Tool ask (complete).
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+				createMessage({ type: "ask", ask: "tool", partial: false }),
+			]),
+		)
+		expect(client.isWaitingForInput()).toBe(true)
+		expect(client.getCurrentAsk()).toBe("tool")
+
+		// 5. User approves, task completes.
+		client.handleMessage(
+			createStateMessage([
+				createMessage({
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.001 }),
+				}),
+				createMessage({ say: "text", text: "I'll help you with that." }),
+				createMessage({ type: "ask", ask: "tool", partial: false }),
+				createMessage({ say: "text", text: "File created." }),
+				createMessage({ type: "ask", ask: "completion_result", partial: false }),
+			]),
+		)
+		expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
+		expect(client.getCurrentAsk()).toBe("completion_result")
+
+		// Verify we saw the expected state transitions.
+		expect(states).toContain(AgentLoopState.STREAMING)
+		expect(states).toContain(AgentLoopState.RUNNING)
+		expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
+		expect(states).toContain(AgentLoopState.IDLE)
+	})
+})
+
+describe("Edge Cases", () => {
+	describe("Messages with missing or empty text field", () => {
+		it("should handle ask message with missing text field", () => {
+			const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
+			// Text is undefined by default.
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("tool")
+		})
+
+		it("should handle ask message with empty text field", () => {
+			const messages = [createMessage({ type: "ask", ask: "followup", partial: false, text: "" })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("followup")
+		})
+
+		it("should handle say message with missing text field", () => {
+			const messages = [createMessage({ say: "text" })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+		})
+	})
+
+	describe("api_req_started edge cases", () => {
+		it("should handle api_req_started with empty text field as streaming", () => {
+			const messages = [createMessage({ say: "api_req_started", text: "" })]
+			const state = detectAgentState(messages)
+			// Empty text is treated as "no text yet" = still in progress (streaming).
+			// This matches the behavior: !message.text is true for "" (falsy).
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+
+		it("should handle api_req_started with invalid JSON", () => {
+			const messages = [createMessage({ say: "api_req_started", text: "not valid json" })]
+			const state = detectAgentState(messages)
+			// Invalid JSON should not crash, should return not streaming.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+
+		it("should handle api_req_started with null text", () => {
+			const messages = [createMessage({ say: "api_req_started", text: undefined })]
+			const state = detectAgentState(messages)
+			// No text means still in progress (streaming).
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+
+		it("should handle api_req_started with cost of 0", () => {
+			const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: 0 }) })]
+			const state = detectAgentState(messages)
+			// cost: 0 is defined (not undefined), so NOT streaming.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+
+		it("should handle api_req_started with cost of null", () => {
+			const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: null }) })]
+			const state = detectAgentState(messages)
+			// cost: null is defined (not undefined), so NOT streaming.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.isStreaming).toBe(false)
+		})
+
+		it("should find api_req_started when it's not the last message", () => {
+			const messages = [
+				createMessage({ say: "api_req_started", text: JSON.stringify({ tokensIn: 100 }) }), // No cost = streaming
+				createMessage({ say: "text", text: "Some text" }),
+			]
+			const state = detectAgentState(messages)
+			// Last message is say:text, but api_req_started has no cost.
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+	})
+
+	describe("Rapid state transitions", () => {
+		it("should handle multiple rapid state changes", () => {
+			const { client } = createMockClient()
+			const states: AgentLoopState[] = []
+
+			client.onStateChange((event) => {
+				states.push(event.currentState.state)
+			})
+
+			// Rapid updates.
+			client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: true })]))
+			client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
+			client.handleMessage(
+				createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
+			)
+
+			// Should have tracked all transitions.
+			expect(states.length).toBeGreaterThanOrEqual(3)
+			expect(states).toContain(AgentLoopState.STREAMING)
+			expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
+			expect(states).toContain(AgentLoopState.IDLE)
+		})
+	})
+
+	describe("Message array edge cases", () => {
+		it("should handle single message array", () => {
+			const messages = [createMessage({ say: "text", text: "Hello" })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+			expect(state.lastMessage).toBeDefined()
+			expect(state.lastMessageTs).toBe(messages[0]!.ts)
+		})
+
+		it("should use last message for state detection", () => {
+			// Multiple messages, last one determines state.
+			const messages = [
+				createMessage({ type: "ask", ask: "tool", partial: false }),
+				createMessage({ say: "text", text: "Tool executed" }),
+				createMessage({ type: "ask", ask: "completion_result", partial: false }),
+			]
+			const state = detectAgentState(messages)
+			// Last message is completion_result, so IDLE.
+			expect(state.state).toBe(AgentLoopState.IDLE)
+			expect(state.currentAsk).toBe("completion_result")
+		})
+
+		it("should handle very long message arrays", () => {
+			// Create many messages.
+			const messages: ClineMessage[] = []
+
+			for (let i = 0; i < 100; i++) {
+				messages.push(createMessage({ say: "text", text: `Message ${i}` }))
+			}
+
+			messages.push(createMessage({ type: "ask", ask: "followup", partial: false }))
+
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state.currentAsk).toBe("followup")
+		})
+	})
+
+	describe("State message edge cases", () => {
+		it("should handle state message with empty clineMessages", () => {
+			const { client } = createMockClient()
+			client.handleMessage({ type: "state", state: { clineMessages: [] } } as unknown as ExtensionMessage)
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+			expect(client.isInitialized()).toBe(true)
+		})
+
+		it("should handle state message with missing clineMessages", () => {
+			const { client } = createMockClient()
+
+			client.handleMessage({
+				type: "state",
+				// eslint-disable-next-line @typescript-eslint/no-explicit-any
+				state: {} as any,
+			})
+
+			// Should not crash, state should remain unchanged.
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+
+		it("should handle state message with missing state field", () => {
+			const { client } = createMockClient()
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			client.handleMessage({ type: "state" } as any)
+
+			// Should not crash
+			expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
+		})
+	})
+
+	describe("Partial to complete transitions", () => {
+		it("should transition from streaming to waiting when partial becomes false", () => {
+			const ts = Date.now()
+			const messages1 = [createMessage({ ts, type: "ask", ask: "tool", partial: true })]
+			const messages2 = [createMessage({ ts, type: "ask", ask: "tool", partial: false })]
+
+			const state1 = detectAgentState(messages1)
+			const state2 = detectAgentState(messages2)
+
+			expect(state1.state).toBe(AgentLoopState.STREAMING)
+			expect(state1.isWaitingForInput).toBe(false)
+
+			expect(state2.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
+			expect(state2.isWaitingForInput).toBe(true)
+		})
+
+		it("should handle partial say messages", () => {
+			const messages = [createMessage({ say: "text", text: "Typing...", partial: true })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.STREAMING)
+			expect(state.isStreaming).toBe(true)
+		})
+	})
+
+	describe("Unknown message types", () => {
+		it("should handle unknown ask types gracefully", () => {
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			const messages = [createMessage({ type: "ask", ask: "unknown_type" as any, partial: false })]
+			const state = detectAgentState(messages)
+			// Unknown ask type should default to RUNNING.
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+		})
+
+		it("should handle unknown say types gracefully", () => {
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			const messages = [createMessage({ say: "unknown_say_type" as any })]
+			const state = detectAgentState(messages)
+			expect(state.state).toBe(AgentLoopState.RUNNING)
+		})
+	})
+})

+ 596 - 0
apps/cli/src/agent/__tests__/extension-host.test.ts

@@ -0,0 +1,596 @@
+// pnpm --filter @roo-code/cli test src/agent/__tests__/extension-host.test.ts
+
+import { EventEmitter } from "events"
+import fs from "fs"
+
+import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
+
+import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
+import { ExtensionClient } from "../extension-client.js"
+import { AgentLoopState } from "../agent-state.js"
+
+vi.mock("@roo-code/vscode-shim", () => ({
+	createVSCodeAPI: vi.fn(() => ({
+		context: { extensionPath: "/test/extension" },
+	})),
+	setRuntimeConfigValues: vi.fn(),
+}))
+
+vi.mock("@/lib/storage/index.js", () => ({
+	createEphemeralStorageDir: vi.fn(() => Promise.resolve("/tmp/roo-cli-test-ephemeral")),
+}))
+
+/**
+ * Create a test ExtensionHost with default options.
+ */
+function createTestHost({
+	mode = "code",
+	provider = "openrouter",
+	model = "test-model",
+	...options
+}: Partial<ExtensionHostOptions> = {}): ExtensionHost {
+	return new ExtensionHost({
+		mode,
+		user: null,
+		provider,
+		model,
+		workspacePath: "/test/workspace",
+		extensionPath: "/test/extension",
+		...options,
+	})
+}
+
+// Type for accessing private members
+type PrivateHost = Record<string, unknown>
+
+/**
+ * Helper to access private members for testing
+ */
+function getPrivate<T>(host: ExtensionHost, key: string): T {
+	return (host as unknown as PrivateHost)[key] as T
+}
+
+/**
+ * Helper to set private members for testing
+ */
+function setPrivate(host: ExtensionHost, key: string, value: unknown): void {
+	;(host as unknown as PrivateHost)[key] = value
+}
+
+/**
+ * Helper to call private methods for testing
+ * This uses a more permissive type to avoid TypeScript errors with private methods
+ */
+function callPrivate<T>(host: ExtensionHost, method: string, ...args: unknown[]): T {
+	const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined
+	if (!fn) throw new Error(`Method ${method} not found`)
+	return fn.apply(host, args)
+}
+
+/**
+ * Helper to spy on private methods
+ * This uses a more permissive type to avoid TypeScript errors with vi.spyOn on private methods
+ */
+function spyOnPrivate(host: ExtensionHost, method: string) {
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	return vi.spyOn(host as any, method)
+}
+
+describe("ExtensionHost", () => {
+	beforeEach(() => {
+		vi.resetAllMocks()
+		// Clean up globals
+		delete (global as Record<string, unknown>).vscode
+		delete (global as Record<string, unknown>).__extensionHost
+	})
+
+	describe("constructor", () => {
+		it("should store options correctly", () => {
+			const options: ExtensionHostOptions = {
+				mode: "code",
+				workspacePath: "/my/workspace",
+				extensionPath: "/my/extension",
+				user: null,
+				apiKey: "test-key",
+				provider: "openrouter",
+				model: "test-model",
+			}
+
+			const host = new ExtensionHost(options)
+
+			// Options are stored but integrationTest is set to true
+			const storedOptions = getPrivate<ExtensionHostOptions>(host, "options")
+			expect(storedOptions.mode).toBe(options.mode)
+			expect(storedOptions.workspacePath).toBe(options.workspacePath)
+			expect(storedOptions.extensionPath).toBe(options.extensionPath)
+			expect(storedOptions.integrationTest).toBe(true) // Always set to true in constructor
+		})
+
+		it("should be an EventEmitter instance", () => {
+			const host = createTestHost()
+			expect(host).toBeInstanceOf(EventEmitter)
+		})
+
+		it("should initialize with default state values", () => {
+			const host = createTestHost()
+
+			expect(getPrivate(host, "isReady")).toBe(false)
+			expect(getPrivate(host, "vscode")).toBeNull()
+			expect(getPrivate(host, "extensionModule")).toBeNull()
+		})
+
+		it("should initialize managers", () => {
+			const host = createTestHost()
+
+			// Should have client, outputManager, promptManager, and askDispatcher
+			expect(getPrivate(host, "client")).toBeDefined()
+			expect(getPrivate(host, "outputManager")).toBeDefined()
+			expect(getPrivate(host, "promptManager")).toBeDefined()
+			expect(getPrivate(host, "askDispatcher")).toBeDefined()
+		})
+	})
+
+	describe("webview provider registration", () => {
+		it("should register webview provider without throwing", () => {
+			const host = createTestHost()
+			const mockProvider = { resolveWebviewView: vi.fn() }
+
+			// registerWebviewProvider is now a no-op, just ensure it doesn't throw
+			expect(() => {
+				host.registerWebviewProvider("test-view", mockProvider)
+			}).not.toThrow()
+		})
+
+		it("should unregister webview provider without throwing", () => {
+			const host = createTestHost()
+			const mockProvider = { resolveWebviewView: vi.fn() }
+
+			host.registerWebviewProvider("test-view", mockProvider)
+
+			// unregisterWebviewProvider is now a no-op, just ensure it doesn't throw
+			expect(() => {
+				host.unregisterWebviewProvider("test-view")
+			}).not.toThrow()
+		})
+
+		it("should handle unregistering non-existent provider gracefully", () => {
+			const host = createTestHost()
+
+			expect(() => {
+				host.unregisterWebviewProvider("non-existent")
+			}).not.toThrow()
+		})
+	})
+
+	describe("webview ready state", () => {
+		describe("isInInitialSetup", () => {
+			it("should return true before webview is ready", () => {
+				const host = createTestHost()
+				expect(host.isInInitialSetup()).toBe(true)
+			})
+
+			it("should return false after markWebviewReady is called", () => {
+				const host = createTestHost()
+				host.markWebviewReady()
+				expect(host.isInInitialSetup()).toBe(false)
+			})
+		})
+
+		describe("markWebviewReady", () => {
+			it("should set isReady to true", () => {
+				const host = createTestHost()
+				host.markWebviewReady()
+				expect(getPrivate(host, "isReady")).toBe(true)
+			})
+
+			it("should send webviewDidLaunch message", () => {
+				const host = createTestHost()
+				const emitSpy = vi.spyOn(host, "emit")
+
+				host.markWebviewReady()
+
+				expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "webviewDidLaunch" })
+			})
+
+			it("should send updateSettings message", () => {
+				const host = createTestHost()
+				const emitSpy = vi.spyOn(host, "emit")
+
+				host.markWebviewReady()
+
+				// Check that updateSettings was called
+				const updateSettingsCall = emitSpy.mock.calls.find(
+					(call) =>
+						call[0] === "webviewMessage" &&
+						typeof call[1] === "object" &&
+						call[1] !== null &&
+						(call[1] as WebviewMessage).type === "updateSettings",
+				)
+				expect(updateSettingsCall).toBeDefined()
+			})
+		})
+	})
+
+	describe("sendToExtension", () => {
+		it("should throw error when extension not ready", () => {
+			const host = createTestHost()
+			const message: WebviewMessage = { type: "requestModes" }
+
+			expect(() => {
+				host.sendToExtension(message)
+			}).toThrow("You cannot send messages to the extension before it is ready")
+		})
+
+		it("should emit webviewMessage event when webview is ready", () => {
+			const host = createTestHost()
+			const emitSpy = vi.spyOn(host, "emit")
+			const message: WebviewMessage = { type: "requestModes" }
+
+			host.markWebviewReady()
+			emitSpy.mockClear() // Clear the markWebviewReady calls
+			host.sendToExtension(message)
+
+			expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message)
+		})
+
+		it("should not throw when webview is ready", () => {
+			const host = createTestHost()
+
+			host.markWebviewReady()
+
+			expect(() => {
+				host.sendToExtension({ type: "requestModes" })
+			}).not.toThrow()
+		})
+	})
+
+	describe("message handling via client", () => {
+		it("should forward extension messages to the client", () => {
+			const host = createTestHost()
+			const client = getPrivate(host, "client") as ExtensionClient
+
+			// Simulate extension message.
+			host.emit("extensionWebviewMessage", {
+				type: "state",
+				state: { clineMessages: [] },
+			} as unknown as ExtensionMessage)
+
+			// Message listener is set up in activate(), which we can't easily call in unit tests.
+			// But we can verify the client exists and has the handleMessage method.
+			expect(typeof client.handleMessage).toBe("function")
+		})
+	})
+
+	describe("public agent state API", () => {
+		it("should return agent state from getAgentState()", () => {
+			const host = createTestHost()
+			const state = host.getAgentState()
+
+			expect(state).toBeDefined()
+			expect(state.state).toBeDefined()
+			expect(state.isWaitingForInput).toBeDefined()
+			expect(state.isRunning).toBeDefined()
+		})
+
+		it("should return isWaitingForInput() status", () => {
+			const host = createTestHost()
+			expect(typeof host.isWaitingForInput()).toBe("boolean")
+		})
+	})
+
+	describe("quiet mode", () => {
+		describe("setupQuietMode", () => {
+			it("should not modify console when integrationTest is true", () => {
+				// By default, constructor sets integrationTest = true
+				const host = createTestHost()
+				const originalLog = console.log
+
+				callPrivate(host, "setupQuietMode")
+
+				// Console should not be modified since integrationTest is true
+				expect(console.log).toBe(originalLog)
+			})
+
+			it("should suppress console when integrationTest is false", () => {
+				const host = createTestHost()
+				const originalLog = console.log
+
+				// Override integrationTest to false
+				const options = getPrivate<ExtensionHostOptions>(host, "options")
+				options.integrationTest = false
+
+				callPrivate(host, "setupQuietMode")
+
+				// Console should be modified
+				expect(console.log).not.toBe(originalLog)
+
+				// Restore for other tests
+				callPrivate(host, "restoreConsole")
+			})
+
+			it("should preserve console.error even when suppressing", () => {
+				const host = createTestHost()
+				const originalError = console.error
+
+				// Override integrationTest to false
+				const options = getPrivate<ExtensionHostOptions>(host, "options")
+				options.integrationTest = false
+
+				callPrivate(host, "setupQuietMode")
+
+				expect(console.error).toBe(originalError)
+
+				callPrivate(host, "restoreConsole")
+			})
+		})
+
+		describe("restoreConsole", () => {
+			it("should restore original console methods when suppressed", () => {
+				const host = createTestHost()
+				const originalLog = console.log
+
+				// Override integrationTest to false to actually suppress
+				const options = getPrivate<ExtensionHostOptions>(host, "options")
+				options.integrationTest = false
+
+				callPrivate(host, "setupQuietMode")
+				callPrivate(host, "restoreConsole")
+
+				expect(console.log).toBe(originalLog)
+			})
+
+			it("should handle case where console was not suppressed", () => {
+				const host = createTestHost()
+
+				expect(() => {
+					callPrivate(host, "restoreConsole")
+				}).not.toThrow()
+			})
+		})
+	})
+
+	describe("dispose", () => {
+		let host: ExtensionHost
+
+		beforeEach(() => {
+			host = createTestHost()
+		})
+
+		it("should remove message listener", async () => {
+			const listener = vi.fn()
+			setPrivate(host, "messageListener", listener)
+			host.on("extensionWebviewMessage", listener)
+
+			await host.dispose()
+
+			expect(getPrivate(host, "messageListener")).toBeNull()
+		})
+
+		it("should call extension deactivate if available", async () => {
+			const deactivateMock = vi.fn()
+			setPrivate(host, "extensionModule", {
+				deactivate: deactivateMock,
+			})
+
+			await host.dispose()
+
+			expect(deactivateMock).toHaveBeenCalled()
+		})
+
+		it("should clear vscode reference", async () => {
+			setPrivate(host, "vscode", { context: {} })
+
+			await host.dispose()
+
+			expect(getPrivate(host, "vscode")).toBeNull()
+		})
+
+		it("should clear extensionModule reference", async () => {
+			setPrivate(host, "extensionModule", {})
+
+			await host.dispose()
+
+			expect(getPrivate(host, "extensionModule")).toBeNull()
+		})
+
+		it("should delete global vscode", async () => {
+			;(global as Record<string, unknown>).vscode = {}
+
+			await host.dispose()
+
+			expect((global as Record<string, unknown>).vscode).toBeUndefined()
+		})
+
+		it("should delete global __extensionHost", async () => {
+			;(global as Record<string, unknown>).__extensionHost = {}
+
+			await host.dispose()
+
+			expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
+		})
+
+		it("should call restoreConsole", async () => {
+			const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole")
+
+			await host.dispose()
+
+			expect(restoreConsoleSpy).toHaveBeenCalled()
+		})
+	})
+
+	describe("runTask", () => {
+		it("should send newTask message when called", async () => {
+			const host = createTestHost()
+			host.markWebviewReady()
+
+			const emitSpy = vi.spyOn(host, "emit")
+			const client = getPrivate(host, "client") as ExtensionClient
+
+			// Start the task (will hang waiting for completion)
+			const taskPromise = host.runTask("test prompt")
+
+			// Emit completion to resolve the promise via the client's emitter
+			const taskCompletedEvent = {
+				success: true,
+				stateInfo: {
+					state: AgentLoopState.IDLE,
+					isWaitingForInput: false,
+					isRunning: false,
+					isStreaming: false,
+					requiredAction: "start_task" as const,
+					description: "Task completed",
+				},
+			}
+			setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
+
+			await taskPromise
+
+			expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
+		})
+
+		it("should resolve when taskCompleted is emitted on client", async () => {
+			const host = createTestHost()
+			host.markWebviewReady()
+
+			const client = getPrivate(host, "client") as ExtensionClient
+			const taskPromise = host.runTask("test prompt")
+
+			// Emit completion after a short delay via the client's emitter
+			const taskCompletedEvent = {
+				success: true,
+				stateInfo: {
+					state: AgentLoopState.IDLE,
+					isWaitingForInput: false,
+					isRunning: false,
+					isStreaming: false,
+					requiredAction: "start_task" as const,
+					description: "Task completed",
+				},
+			}
+			setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
+
+			await expect(taskPromise).resolves.toBeUndefined()
+		})
+	})
+
+	describe("initial settings", () => {
+		it("should set mode from options", () => {
+			const host = createTestHost({ mode: "architect" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.mode).toBe("architect")
+		})
+
+		it("should enable auto-approval in non-interactive mode", () => {
+			const host = createTestHost({ nonInteractive: true })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.autoApprovalEnabled).toBe(true)
+			expect(initialSettings.alwaysAllowReadOnly).toBe(true)
+			expect(initialSettings.alwaysAllowWrite).toBe(true)
+			expect(initialSettings.alwaysAllowExecute).toBe(true)
+		})
+
+		it("should disable auto-approval in interactive mode", () => {
+			const host = createTestHost({ nonInteractive: false })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.autoApprovalEnabled).toBe(false)
+		})
+
+		it("should set reasoning effort when specified", () => {
+			const host = createTestHost({ reasoningEffort: "high" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.enableReasoningEffort).toBe(true)
+			expect(initialSettings.reasoningEffort).toBe("high")
+		})
+
+		it("should disable reasoning effort when set to disabled", () => {
+			const host = createTestHost({ reasoningEffort: "disabled" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.enableReasoningEffort).toBe(false)
+		})
+
+		it("should not set reasoning effort when unspecified", () => {
+			const host = createTestHost({ reasoningEffort: "unspecified" })
+
+			const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
+			expect(initialSettings.enableReasoningEffort).toBeUndefined()
+			expect(initialSettings.reasoningEffort).toBeUndefined()
+		})
+	})
+
+	describe("ephemeral mode", () => {
+		it("should store ephemeral option correctly", () => {
+			const host = createTestHost({ ephemeral: true })
+
+			const options = getPrivate<ExtensionHostOptions>(host, "options")
+			expect(options.ephemeral).toBe(true)
+		})
+
+		it("should default ephemeralStorageDir to null", () => {
+			const host = createTestHost()
+
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+		})
+
+		it("should clean up ephemeral storage directory on dispose", async () => {
+			const host = createTestHost({ ephemeral: true })
+
+			// Set up a mock ephemeral storage directory
+			const mockEphemeralDir = "/tmp/roo-cli-test-ephemeral-cleanup"
+			setPrivate(host, "ephemeralStorageDir", mockEphemeralDir)
+
+			// Mock fs.promises.rm
+			const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined)
+
+			await host.dispose()
+
+			expect(rmMock).toHaveBeenCalledWith(mockEphemeralDir, { recursive: true, force: true })
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+
+			rmMock.mockRestore()
+		})
+
+		it("should not clean up when ephemeralStorageDir is null", async () => {
+			const host = createTestHost()
+
+			// ephemeralStorageDir is null by default
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+
+			const rmMock = vi.spyOn(fs.promises, "rm").mockResolvedValue(undefined)
+
+			await host.dispose()
+
+			// rm should not be called when there's no ephemeral storage
+			expect(rmMock).not.toHaveBeenCalled()
+
+			rmMock.mockRestore()
+		})
+
+		it("should handle ephemeral storage cleanup errors gracefully", async () => {
+			const host = createTestHost({ ephemeral: true })
+
+			// Set up a mock ephemeral storage directory
+			setPrivate(host, "ephemeralStorageDir", "/tmp/roo-cli-test-ephemeral-error")
+
+			// Mock fs.promises.rm to throw an error
+			const rmMock = vi.spyOn(fs.promises, "rm").mockRejectedValue(new Error("Cleanup failed"))
+
+			// dispose should not throw even if cleanup fails
+			await expect(host.dispose()).resolves.toBeUndefined()
+
+			rmMock.mockRestore()
+		})
+
+		it("should not affect normal mode when ephemeral is false", () => {
+			const host = createTestHost({ ephemeral: false })
+
+			const options = getPrivate<ExtensionHostOptions>(host, "options")
+			expect(options.ephemeral).toBe(false)
+			expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
+		})
+	})
+})

+ 466 - 0
apps/cli/src/agent/agent-state.ts

@@ -0,0 +1,466 @@
+/**
+ * Agent Loop State Detection
+ *
+ * This module provides the core logic for detecting the current state of the
+ * Roo Code agent loop. The state is determined by analyzing the clineMessages
+ * array, specifically the last message's type and properties.
+ *
+ * Key insight: The agent loop stops whenever a message with `type: "ask"` arrives,
+ * and the specific `ask` value determines what kind of response the agent is waiting for.
+ */
+
+import { ClineMessage, ClineAsk, isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "@roo-code/types"
+
+// =============================================================================
+// Agent Loop State Enum
+// =============================================================================
+
+/**
+ * The possible states of the agent loop.
+ *
+ * State Machine:
+ * ```
+ *                    ┌─────────────────┐
+ *                    │   NO_TASK       │ (initial state)
+ *                    └────────┬────────┘
+ *                             │ newTask
+ *                             ▼
+ *              ┌─────────────────────────────┐
+ *         ┌───▶│         RUNNING             │◀───┐
+ *         │    └──────────┬──────────────────┘    │
+ *         │               │                       │
+ *         │    ┌──────────┼──────────────┐        │
+ *         │    │          │              │        │
+ *         │    ▼          ▼              ▼        │
+ *         │ ┌──────┐  ┌─────────┐  ┌──────────┐   │
+ *         │ │STREAM│  │INTERACT │  │  IDLE    │   │
+ *         │ │ ING  │  │  IVE    │  │          │   │
+ *         │ └──┬───┘  └────┬────┘  └────┬─────┘   │
+ *         │    │           │            │         │
+ *         │    │ done      │ approved   │ newTask │
+ *         └────┴───────────┴────────────┘         │
+ *                                                 │
+ *         ┌──────────────┐                        │
+ *         │  RESUMABLE   │────────────────────────┘
+ *         └──────────────┘  resumed
+ * ```
+ */
+export enum AgentLoopState {
+	/**
+	 * No active task. This is the initial state before any task is started,
+	 * or after a task has been cleared.
+	 */
+	NO_TASK = "no_task",
+
+	/**
+	 * Agent is actively processing. This means:
+	 * - The last message is a "say" type (informational), OR
+	 * - The last message is a non-blocking ask (command_output)
+	 *
+	 * In this state, the agent may be:
+	 * - Executing tools
+	 * - Thinking/reasoning
+	 * - Processing between API calls
+	 */
+	RUNNING = "running",
+
+	/**
+	 * Agent is streaming a response. This is detected when:
+	 * - `partial === true` on the last message, OR
+	 * - The last `api_req_started` message has no `cost` in its text field
+	 *
+	 * Do NOT consider the agent "waiting" while streaming.
+	 */
+	STREAMING = "streaming",
+
+	/**
+	 * Agent is waiting for user approval or input. This includes:
+	 * - Tool approvals (file operations)
+	 * - Command execution permission
+	 * - Browser action permission
+	 * - MCP server permission
+	 * - Follow-up questions
+	 *
+	 * User must approve, reject, or provide input to continue.
+	 */
+	WAITING_FOR_INPUT = "waiting_for_input",
+
+	/**
+	 * Task is in an idle/terminal state. This includes:
+	 * - Task completed successfully (completion_result)
+	 * - API request failed (api_req_failed)
+	 * - Too many errors (mistake_limit_reached)
+	 * - Auto-approval limit reached
+	 * - Completed task waiting to be resumed
+	 *
+	 * User can start a new task or retry.
+	 */
+	IDLE = "idle",
+
+	/**
+	 * Task is paused and can be resumed. This happens when:
+	 * - User navigated away from a task
+	 * - Extension was restarted mid-task
+	 *
+	 * User can resume or abandon the task.
+	 */
+	RESUMABLE = "resumable",
+}
+
+// =============================================================================
+// Detailed State Info
+// =============================================================================
+
+/**
+ * What action the user should/can take in the current state.
+ */
+export type RequiredAction =
+	| "none" // No action needed (running/streaming)
+	| "approve" // Can approve/reject (tool, command, browser, mcp)
+	| "answer" // Need to answer a question (followup)
+	| "retry_or_new_task" // Can retry or start new task (api_req_failed)
+	| "proceed_or_new_task" // Can proceed or start new task (mistake_limit)
+	| "start_task" // Should start a new task (completion_result)
+	| "resume_or_abandon" // Can resume or abandon (resume_task)
+	| "start_new_task" // Should start new task (resume_completed_task, no_task)
+	| "continue_or_abort" // Can continue or abort (command_output)
+
+/**
+ * Detailed information about the current agent state.
+ * Provides everything needed to render UI or make decisions.
+ */
+export interface AgentStateInfo {
+	/** The high-level state of the agent loop */
+	state: AgentLoopState
+
+	/** Whether the agent is waiting for user input/action */
+	isWaitingForInput: boolean
+
+	/** Whether the agent loop is actively processing */
+	isRunning: boolean
+
+	/** Whether content is being streamed */
+	isStreaming: boolean
+
+	/** The specific ask type if waiting on an ask, undefined otherwise */
+	currentAsk?: ClineAsk
+
+	/** What action the user should/can take */
+	requiredAction: RequiredAction
+
+	/** The timestamp of the last message, useful for tracking */
+	lastMessageTs?: number
+
+	/** The full last message for advanced usage */
+	lastMessage?: ClineMessage
+
+	/** Human-readable description of the current state */
+	description: string
+}
+
+// =============================================================================
+// State Detection Functions
+// =============================================================================
+
+/**
+ * Structure of the text field in api_req_started messages.
+ * Used to determine if the API request has completed (cost is defined).
+ */
+export interface ApiReqStartedText {
+	cost?: number // Undefined while streaming, defined when complete.
+	tokensIn?: number
+	tokensOut?: number
+	cacheWrites?: number
+	cacheReads?: number
+}
+
+/**
+ * Check if an API request is still in progress (streaming).
+ *
+ * API requests are considered in-progress when:
+ * - An api_req_started message exists
+ * - Its text field, when parsed, has `cost: undefined`
+ *
+ * Once the request completes, the cost field will be populated.
+ */
+function isApiRequestInProgress(messages: ClineMessage[]): boolean {
+	// Find the last api_req_started message.
+	// Using reverse iteration for efficiency (most recent first).
+	for (let i = messages.length - 1; i >= 0; i--) {
+		const message = messages[i]
+
+		if (!message) {
+			continue
+		}
+
+		if (message.say === "api_req_started") {
+			if (!message.text) {
+				// No text yet means still in progress.
+				return true
+			}
+
+			try {
+				const data: ApiReqStartedText = JSON.parse(message.text)
+				// cost is undefined while streaming, defined when complete.
+				return data.cost === undefined
+			} catch {
+				// Parse error - assume not in progress.
+				return false
+			}
+		}
+	}
+	return false
+}
+
+/**
+ * Determine the required action based on the current ask type.
+ */
+function getRequiredAction(ask: ClineAsk): RequiredAction {
+	switch (ask) {
+		case "followup":
+			return "answer"
+		case "command":
+		case "tool":
+		case "browser_action_launch":
+		case "use_mcp_server":
+			return "approve"
+		case "command_output":
+			return "continue_or_abort"
+		case "api_req_failed":
+			return "retry_or_new_task"
+		case "mistake_limit_reached":
+			return "proceed_or_new_task"
+		case "completion_result":
+			return "start_task"
+		case "resume_task":
+			return "resume_or_abandon"
+		case "resume_completed_task":
+		case "auto_approval_max_req_reached":
+			return "start_new_task"
+		default:
+			return "none"
+	}
+}
+
+/**
+ * Get a human-readable description for the current state.
+ */
+function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string {
+	switch (state) {
+		case AgentLoopState.NO_TASK:
+			return "No active task. Ready to start a new task."
+
+		case AgentLoopState.RUNNING:
+			return "Agent is actively processing."
+
+		case AgentLoopState.STREAMING:
+			return "Agent is streaming a response."
+
+		case AgentLoopState.WAITING_FOR_INPUT:
+			switch (ask) {
+				case "followup":
+					return "Agent is asking a follow-up question. Please provide an answer."
+				case "command":
+					return "Agent wants to execute a command. Approve or reject."
+				case "tool":
+					return "Agent wants to perform a file operation. Approve or reject."
+				case "browser_action_launch":
+					return "Agent wants to use the browser. Approve or reject."
+				case "use_mcp_server":
+					return "Agent wants to use an MCP server. Approve or reject."
+				default:
+					return "Agent is waiting for user input."
+			}
+
+		case AgentLoopState.IDLE:
+			switch (ask) {
+				case "completion_result":
+					return "Task completed successfully. You can provide feedback or start a new task."
+				case "api_req_failed":
+					return "API request failed. You can retry or start a new task."
+				case "mistake_limit_reached":
+					return "Too many errors encountered. You can proceed anyway or start a new task."
+				case "auto_approval_max_req_reached":
+					return "Auto-approval limit reached. Manual approval required."
+				case "resume_completed_task":
+					return "Previously completed task. Start a new task to continue."
+				default:
+					return "Task is idle."
+			}
+
+		case AgentLoopState.RESUMABLE:
+			return "Task is paused. You can resume or start a new task."
+
+		default:
+			return "Unknown state."
+	}
+}
+
+/**
+ * Detect the current state of the agent loop from the clineMessages array.
+ *
+ * This is the main state detection function. It analyzes the messages array
+ * and returns detailed information about the current agent state.
+ *
+ * @param messages - The clineMessages array from extension state
+ * @returns Detailed state information
+ */
+export function detectAgentState(messages: ClineMessage[]): AgentStateInfo {
+	// No messages means no task
+	if (!messages || messages.length === 0) {
+		return {
+			state: AgentLoopState.NO_TASK,
+			isWaitingForInput: false,
+			isRunning: false,
+			isStreaming: false,
+			requiredAction: "start_new_task",
+			description: getStateDescription(AgentLoopState.NO_TASK),
+		}
+	}
+
+	const lastMessage = messages[messages.length - 1]
+
+	// Guard against undefined (should never happen after length check, but TypeScript requires it)
+	if (!lastMessage) {
+		return {
+			state: AgentLoopState.NO_TASK,
+			isWaitingForInput: false,
+			isRunning: false,
+			isStreaming: false,
+			requiredAction: "start_new_task",
+			description: getStateDescription(AgentLoopState.NO_TASK),
+		}
+	}
+
+	// Check if the message is still streaming (partial)
+	// This is the PRIMARY indicator of streaming
+	if (lastMessage.partial === true) {
+		return {
+			state: AgentLoopState.STREAMING,
+			isWaitingForInput: false,
+			isRunning: true,
+			isStreaming: true,
+			currentAsk: lastMessage.ask,
+			requiredAction: "none",
+			lastMessageTs: lastMessage.ts,
+			lastMessage,
+			description: getStateDescription(AgentLoopState.STREAMING),
+		}
+	}
+
+	// Handle "ask" type messages
+	if (lastMessage.type === "ask" && lastMessage.ask) {
+		const ask = lastMessage.ask
+
+		// Non-blocking asks (command_output) - agent is running but can be interrupted
+		if (isNonBlockingAsk(ask)) {
+			return {
+				state: AgentLoopState.RUNNING,
+				isWaitingForInput: false,
+				isRunning: true,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: "continue_or_abort",
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: "Command is running. You can continue or abort.",
+			}
+		}
+
+		// Idle asks - task has stopped
+		if (isIdleAsk(ask)) {
+			return {
+				state: AgentLoopState.IDLE,
+				isWaitingForInput: true, // User needs to decide what to do next
+				isRunning: false,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: getRequiredAction(ask),
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: getStateDescription(AgentLoopState.IDLE, ask),
+			}
+		}
+
+		// Resumable asks - task is paused
+		if (isResumableAsk(ask)) {
+			return {
+				state: AgentLoopState.RESUMABLE,
+				isWaitingForInput: true,
+				isRunning: false,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: getRequiredAction(ask),
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: getStateDescription(AgentLoopState.RESUMABLE, ask),
+			}
+		}
+
+		// Interactive asks - waiting for approval/input
+		if (isInteractiveAsk(ask)) {
+			return {
+				state: AgentLoopState.WAITING_FOR_INPUT,
+				isWaitingForInput: true,
+				isRunning: false,
+				isStreaming: false,
+				currentAsk: ask,
+				requiredAction: getRequiredAction(ask),
+				lastMessageTs: lastMessage.ts,
+				lastMessage,
+				description: getStateDescription(AgentLoopState.WAITING_FOR_INPUT, ask),
+			}
+		}
+	}
+
+	// For "say" type messages, check if API request is in progress
+	if (isApiRequestInProgress(messages)) {
+		return {
+			state: AgentLoopState.STREAMING,
+			isWaitingForInput: false,
+			isRunning: true,
+			isStreaming: true,
+			requiredAction: "none",
+			lastMessageTs: lastMessage.ts,
+			lastMessage,
+			description: getStateDescription(AgentLoopState.STREAMING),
+		}
+	}
+
+	// Default: agent is running
+	return {
+		state: AgentLoopState.RUNNING,
+		isWaitingForInput: false,
+		isRunning: true,
+		isStreaming: false,
+		requiredAction: "none",
+		lastMessageTs: lastMessage.ts,
+		lastMessage,
+		description: getStateDescription(AgentLoopState.RUNNING),
+	}
+}
+
+/**
+ * Quick check: Is the agent waiting for user input?
+ *
+ * This is a convenience function for simple use cases where you just need
+ * to know if user action is required.
+ */
+export function isAgentWaitingForInput(messages: ClineMessage[]): boolean {
+	return detectAgentState(messages).isWaitingForInput
+}
+
+/**
+ * Quick check: Is the agent actively running (not waiting)?
+ */
+export function isAgentRunning(messages: ClineMessage[]): boolean {
+	const state = detectAgentState(messages)
+	return state.isRunning && !state.isWaitingForInput
+}
+
+/**
+ * Quick check: Is content currently streaming?
+ */
+export function isContentStreaming(messages: ClineMessage[]): boolean {
+	return detectAgentState(messages).isStreaming
+}

+ 681 - 0
apps/cli/src/agent/ask-dispatcher.ts

@@ -0,0 +1,681 @@
+/**
+ * AskDispatcher - Routes ask messages to appropriate handlers
+ *
+ * This dispatcher is responsible for:
+ * - Categorizing ask types using type guards from client module
+ * - Routing to the appropriate handler based on ask category
+ * - Coordinating between OutputManager and PromptManager
+ * - Tracking which asks have been handled (to avoid duplicates)
+ *
+ * Design notes:
+ * - Uses isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk type guards
+ * - Single responsibility: Ask routing and handling only
+ * - Delegates output to OutputManager, input to PromptManager
+ * - Sends responses back through a provided callback
+ */
+
+import {
+	type WebviewMessage,
+	type ClineMessage,
+	type ClineAsk,
+	type ClineAskResponse,
+	isIdleAsk,
+	isInteractiveAsk,
+	isResumableAsk,
+	isNonBlockingAsk,
+} from "@roo-code/types"
+import { debugLog } from "@roo-code/core/cli"
+
+import { FOLLOWUP_TIMEOUT_SECONDS } from "@/types/index.js"
+
+import type { OutputManager } from "./output-manager.js"
+import type { PromptManager } from "./prompt-manager.js"
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Configuration for AskDispatcher.
+ */
+export interface AskDispatcherOptions {
+	/**
+	 * OutputManager for displaying ask-related output.
+	 */
+	outputManager: OutputManager
+
+	/**
+	 * PromptManager for collecting user input.
+	 */
+	promptManager: PromptManager
+
+	/**
+	 * Callback to send responses to the extension.
+	 */
+	sendMessage: (message: WebviewMessage) => void
+
+	/**
+	 * Whether running in non-interactive mode (auto-approve).
+	 */
+	nonInteractive?: boolean
+
+	/**
+	 * Whether to disable ask handling (for TUI mode).
+	 * In TUI mode, the TUI handles asks directly.
+	 */
+	disabled?: boolean
+}
+
+/**
+ * Result of handling an ask.
+ */
+export interface AskHandleResult {
+	/** Whether the ask was handled */
+	handled: boolean
+	/** The response sent (if any) */
+	response?: ClineAskResponse
+	/** Any error that occurred */
+	error?: Error
+}
+
+// =============================================================================
+// AskDispatcher Class
+// =============================================================================
+
+export class AskDispatcher {
+	private outputManager: OutputManager
+	private promptManager: PromptManager
+	private sendMessage: (message: WebviewMessage) => void
+	private nonInteractive: boolean
+	private disabled: boolean
+
+	/**
+	 * Track which asks have been handled to avoid duplicates.
+	 * Key: message ts
+	 */
+	private handledAsks = new Set<number>()
+
+	constructor(options: AskDispatcherOptions) {
+		this.outputManager = options.outputManager
+		this.promptManager = options.promptManager
+		this.sendMessage = options.sendMessage
+		this.nonInteractive = options.nonInteractive ?? false
+		this.disabled = options.disabled ?? false
+	}
+
+	// ===========================================================================
+	// Public API
+	// ===========================================================================
+
+	/**
+	 * Handle an ask message.
+	 * Routes to the appropriate handler based on ask type.
+	 *
+	 * @param message - The ClineMessage with type="ask"
+	 * @returns Promise<AskHandleResult>
+	 */
+	async handleAsk(message: ClineMessage): Promise<AskHandleResult> {
+		// Disabled in TUI mode - TUI handles asks directly
+		if (this.disabled) {
+			return { handled: false }
+		}
+
+		const ts = message.ts
+		const ask = message.ask
+		const text = message.text || ""
+
+		// Check if already handled
+		if (this.handledAsks.has(ts)) {
+			return { handled: true }
+		}
+
+		// Must be an ask message
+		if (message.type !== "ask" || !ask) {
+			return { handled: false }
+		}
+
+		// Skip partial messages (wait for complete)
+		if (message.partial) {
+			return { handled: false }
+		}
+
+		// Mark as being handled
+		this.handledAsks.add(ts)
+
+		try {
+			// Route based on ask category
+			if (isNonBlockingAsk(ask)) {
+				return await this.handleNonBlockingAsk(ts, ask, text)
+			}
+
+			if (isIdleAsk(ask)) {
+				return await this.handleIdleAsk(ts, ask, text)
+			}
+
+			if (isResumableAsk(ask)) {
+				return await this.handleResumableAsk(ts, ask, text)
+			}
+
+			if (isInteractiveAsk(ask)) {
+				return await this.handleInteractiveAsk(ts, ask, text)
+			}
+
+			// Unknown ask type - log and handle generically
+			debugLog("[AskDispatcher] Unknown ask type", { ask, ts })
+			return await this.handleUnknownAsk(ts, ask, text)
+		} catch (error) {
+			// Re-allow handling on error
+			this.handledAsks.delete(ts)
+			return {
+				handled: false,
+				error: error instanceof Error ? error : new Error(String(error)),
+			}
+		}
+	}
+
+	/**
+	 * Check if an ask has been handled.
+	 */
+	isHandled(ts: number): boolean {
+		return this.handledAsks.has(ts)
+	}
+
+	/**
+	 * Clear handled asks (call when starting new task).
+	 */
+	clear(): void {
+		this.handledAsks.clear()
+	}
+
+	// ===========================================================================
+	// Category Handlers
+	// ===========================================================================
+
+	/**
+	 * Handle non-blocking asks (command_output).
+	 * These don't actually block the agent - just need acknowledgment.
+	 */
+	private async handleNonBlockingAsk(_ts: number, _ask: ClineAsk, _text: string): Promise<AskHandleResult> {
+		// command_output - output is handled by OutputManager
+		// Just send approval to continue
+		this.sendApprovalResponse(true)
+		return { handled: true, response: "yesButtonClicked" }
+	}
+
+	/**
+	 * Handle idle asks (completion_result, api_req_failed, etc.).
+	 * These indicate the task has stopped.
+	 */
+	private async handleIdleAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		switch (ask) {
+			case "completion_result":
+				// Task complete - nothing to do here, TaskCompleted event handles it
+				return { handled: true }
+
+			case "api_req_failed":
+				return await this.handleApiFailedRetry(ts, text)
+
+			case "mistake_limit_reached":
+				return await this.handleMistakeLimitReached(ts, text)
+
+			case "resume_completed_task":
+				return await this.handleResumeTask(ts, ask, text)
+
+			case "auto_approval_max_req_reached":
+				return await this.handleAutoApprovalMaxReached(ts, text)
+
+			default:
+				return { handled: false }
+		}
+	}
+
+	/**
+	 * Handle resumable asks (resume_task).
+	 */
+	private async handleResumableAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		return await this.handleResumeTask(ts, ask, text)
+	}
+
+	/**
+	 * Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server).
+	 * These require user approval or input.
+	 */
+	private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		switch (ask) {
+			case "followup":
+				return await this.handleFollowupQuestion(ts, text)
+
+			case "command":
+				return await this.handleCommandApproval(ts, text)
+
+			case "tool":
+				return await this.handleToolApproval(ts, text)
+
+			case "browser_action_launch":
+				return await this.handleBrowserApproval(ts, text)
+
+			case "use_mcp_server":
+				return await this.handleMcpApproval(ts, text)
+
+			default:
+				return { handled: false }
+		}
+	}
+
+	/**
+	 * Handle unknown ask types.
+	 */
+	private async handleUnknownAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		if (this.nonInteractive) {
+			if (text) {
+				this.outputManager.output(`\n[${ask}]`, text)
+			}
+			return { handled: true }
+		}
+
+		return await this.handleGenericApproval(ts, ask, text)
+	}
+
+	// ===========================================================================
+	// Specific Ask Handlers
+	// ===========================================================================
+
+	/**
+	 * Handle followup questions - prompt for text input with suggestions.
+	 */
+	private async handleFollowupQuestion(ts: number, text: string): Promise<AskHandleResult> {
+		let question = text
+		let suggestions: Array<{ answer: string; mode?: string | null }> = []
+
+		try {
+			const data = JSON.parse(text)
+			question = data.question || text
+			suggestions = Array.isArray(data.suggest) ? data.suggest : []
+		} catch {
+			// Use raw text if not JSON
+		}
+
+		this.outputManager.output("\n[question]", question)
+
+		if (suggestions.length > 0) {
+			this.outputManager.output("\nSuggested answers:")
+			suggestions.forEach((suggestion, index) => {
+				const suggestionText = suggestion.answer || String(suggestion)
+				const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : ""
+				this.outputManager.output(`  ${index + 1}. ${suggestionText}${modeHint}`)
+			})
+			this.outputManager.output("")
+		}
+
+		const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null
+		const defaultAnswer = firstSuggestion?.answer ?? ""
+
+		if (this.nonInteractive) {
+			// Use timeout prompt in non-interactive mode
+			const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000
+			const result = await this.promptManager.promptWithTimeout(
+				suggestions.length > 0
+					? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `
+					: `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `,
+				timeoutMs,
+				defaultAnswer,
+			)
+
+			let responseText = result.value.trim()
+			responseText = this.resolveNumberedSuggestion(responseText, suggestions)
+
+			if (result.timedOut || result.cancelled) {
+				this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
+			}
+
+			this.sendFollowupResponse(responseText)
+			return { handled: true, response: "messageResponse" }
+		}
+
+		// Interactive mode
+		try {
+			const answer = await this.promptManager.promptForInput(
+				suggestions.length > 0
+					? `Enter number (1-${suggestions.length}) or type your answer: `
+					: "Your answer: ",
+			)
+
+			let responseText = answer.trim()
+			responseText = this.resolveNumberedSuggestion(responseText, suggestions)
+
+			this.sendFollowupResponse(responseText)
+			return { handled: true, response: "messageResponse" }
+		} catch {
+			this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
+			this.sendFollowupResponse(defaultAnswer)
+			return { handled: true, response: "messageResponse" }
+		}
+	}
+
+	/**
+	 * Handle command execution approval.
+	 */
+	private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[command request]")
+		this.outputManager.output(`  Command: ${text || "(no command specified)"}`)
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Execute this command? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle tool execution approval.
+	 */
+	private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
+		let toolName = "unknown"
+		let toolInfo: Record<string, unknown> = {}
+
+		try {
+			toolInfo = JSON.parse(text) as Record<string, unknown>
+			toolName = (toolInfo.tool as string) || "unknown"
+		} catch {
+			// Use raw text if not JSON
+		}
+
+		const isProtected = toolInfo.isProtected === true
+
+		if (isProtected) {
+			this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`)
+			this.outputManager.output(`⚠️  WARNING: This tool wants to modify a protected configuration file.`)
+			this.outputManager.output(
+				`    Protected files include .rooignore, .roo/*, and other sensitive config files.`,
+			)
+		} else {
+			this.outputManager.output(`\n[Tool Request] ${toolName}`)
+		}
+
+		// Display tool details
+		for (const [key, value] of Object.entries(toolInfo)) {
+			if (key === "tool" || key === "isProtected") continue
+
+			let displayValue: string
+			if (typeof value === "string") {
+				displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value
+			} else if (typeof value === "object" && value !== null) {
+				const json = JSON.stringify(value)
+				displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json
+			} else {
+				displayValue = String(value)
+			}
+
+			this.outputManager.output(`  ${key}: ${displayValue}`)
+		}
+
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings (unless protected)
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Approve this action? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle browser action approval.
+	 */
+	private async handleBrowserApproval(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[browser action request]")
+		if (text) {
+			this.outputManager.output(`  Action: ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle MCP server access approval.
+	 */
+	private async handleMcpApproval(ts: number, text: string): Promise<AskHandleResult> {
+		let serverName = "unknown"
+		let toolName = ""
+		let resourceUri = ""
+
+		try {
+			const mcpInfo = JSON.parse(text)
+			serverName = mcpInfo.server_name || "unknown"
+
+			if (mcpInfo.type === "use_mcp_tool") {
+				toolName = mcpInfo.tool_name || ""
+			} else if (mcpInfo.type === "access_mcp_resource") {
+				resourceUri = mcpInfo.uri || ""
+			}
+		} catch {
+			// Use raw text if not JSON
+		}
+
+		this.outputManager.output("\n[mcp request]")
+		this.outputManager.output(`  Server: ${serverName}`)
+		if (toolName) {
+			this.outputManager.output(`  Tool: ${toolName}`)
+		}
+		if (resourceUri) {
+			this.outputManager.output(`  Resource: ${resourceUri}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-approved by extension settings
+			return { handled: true }
+		}
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Allow MCP access? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle API request failed - retry prompt.
+	 */
+	private async handleApiFailedRetry(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[api request failed]")
+		this.outputManager.output(`  Error: ${text || "Unknown error"}`)
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			this.outputManager.output("\n[retrying api request]")
+			// Auto-retry in non-interactive mode
+			return { handled: true }
+		}
+
+		try {
+			const retry = await this.promptManager.promptForYesNo("Retry the request? (y/n): ")
+			this.sendApprovalResponse(retry)
+			return { handled: true, response: retry ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle mistake limit reached.
+	 */
+	private async handleMistakeLimitReached(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[mistake limit reached]")
+		if (text) {
+			this.outputManager.output(`  Details: ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-proceed in non-interactive mode
+			this.sendApprovalResponse(true)
+			return { handled: true, response: "yesButtonClicked" }
+		}
+
+		try {
+			const proceed = await this.promptManager.promptForYesNo("Continue anyway? (y/n): ")
+			this.sendApprovalResponse(proceed)
+			return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle auto-approval max reached.
+	 */
+	private async handleAutoApprovalMaxReached(ts: number, text: string): Promise<AskHandleResult> {
+		this.outputManager.output("\n[auto-approval limit reached]")
+		if (text) {
+			this.outputManager.output(`  Details: ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			// Auto-proceed in non-interactive mode
+			this.sendApprovalResponse(true)
+			return { handled: true, response: "yesButtonClicked" }
+		}
+
+		try {
+			const proceed = await this.promptManager.promptForYesNo("Continue with manual approval? (y/n): ")
+			this.sendApprovalResponse(proceed)
+			return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle task resume prompt.
+	 */
+	private async handleResumeTask(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		const isCompleted = ask === "resume_completed_task"
+		this.outputManager.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`)
+		if (text) {
+			this.outputManager.output(`  ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		if (this.nonInteractive) {
+			this.outputManager.output("\n[continuing task]")
+			// Auto-resume in non-interactive mode
+			this.sendApprovalResponse(true)
+			return { handled: true, response: "yesButtonClicked" }
+		}
+
+		try {
+			const resume = await this.promptManager.promptForYesNo("Continue with this task? (y/n): ")
+			this.sendApprovalResponse(resume)
+			return { handled: true, response: resume ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	/**
+	 * Handle generic approval prompts for unknown ask types.
+	 */
+	private async handleGenericApproval(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
+		this.outputManager.output(`\n[${ask}]`)
+		if (text) {
+			this.outputManager.output(`  ${text}`)
+		}
+		this.outputManager.markDisplayed(ts, text || "", false)
+
+		try {
+			const approved = await this.promptManager.promptForYesNo("Approve? (y/n): ")
+			this.sendApprovalResponse(approved)
+			return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
+		} catch {
+			this.outputManager.output("[Defaulting to: no]")
+			this.sendApprovalResponse(false)
+			return { handled: true, response: "noButtonClicked" }
+		}
+	}
+
+	// ===========================================================================
+	// Response Helpers
+	// ===========================================================================
+
+	/**
+	 * Send a followup response (text answer) to the extension.
+	 */
+	private sendFollowupResponse(text: string): void {
+		this.sendMessage({ type: "askResponse", askResponse: "messageResponse", text })
+	}
+
+	/**
+	 * Send an approval response (yes/no) to the extension.
+	 */
+	private sendApprovalResponse(approved: boolean): void {
+		this.sendMessage({
+			type: "askResponse",
+			askResponse: approved ? "yesButtonClicked" : "noButtonClicked",
+		})
+	}
+
+	/**
+	 * Resolve a numbered suggestion selection.
+	 */
+	private resolveNumberedSuggestion(
+		input: string,
+		suggestions: Array<{ answer: string; mode?: string | null }>,
+	): string {
+		const num = parseInt(input, 10)
+		if (!isNaN(num) && num >= 1 && num <= suggestions.length) {
+			const selectedSuggestion = suggestions[num - 1]
+			if (selectedSuggestion) {
+				const selected = selectedSuggestion.answer || String(selectedSuggestion)
+				this.outputManager.output(`Selected: ${selected}`)
+				return selected
+			}
+		}
+		return input
+	}
+}

+ 372 - 0
apps/cli/src/agent/events.ts

@@ -0,0 +1,372 @@
+/**
+ * Event System for Agent State Changes
+ *
+ * This module provides a strongly-typed event emitter specifically designed
+ * for tracking agent state changes. It uses Node.js EventEmitter under the hood
+ * but provides type safety for all events.
+ */
+
+import { EventEmitter } from "events"
+
+import { ClineMessage, ClineAsk } from "@roo-code/types"
+
+import type { AgentStateInfo } from "./agent-state.js"
+
+// =============================================================================
+// Event Types
+// =============================================================================
+
+/**
+ * All events that can be emitted by the client.
+ *
+ * Design note: We use a string literal union type for event names to ensure
+ * type safety when subscribing to events. The payload type is determined by
+ * the event name.
+ */
+export interface ClientEventMap {
+	/**
+	 * Emitted whenever the agent state changes.
+	 * This is the primary event for tracking state.
+	 */
+	stateChange: AgentStateChangeEvent
+
+	/**
+	 * Emitted when a new message is added to the message list.
+	 */
+	message: ClineMessage
+
+	/**
+	 * Emitted when an existing message is updated (e.g., partial -> complete).
+	 */
+	messageUpdated: ClineMessage
+
+	/**
+	 * Emitted when the agent starts waiting for user input.
+	 * Convenience event - you can also use stateChange.
+	 */
+	waitingForInput: WaitingForInputEvent
+
+	/**
+	 * Emitted when the agent stops waiting and resumes running.
+	 */
+	resumedRunning: void
+
+	/**
+	 * Emitted when the agent starts streaming content.
+	 */
+	streamingStarted: void
+
+	/**
+	 * Emitted when streaming ends.
+	 */
+	streamingEnded: void
+
+	/**
+	 * Emitted when a task completes (either successfully or with error).
+	 */
+	taskCompleted: TaskCompletedEvent
+
+	/**
+	 * Emitted when a task is cleared/cancelled.
+	 */
+	taskCleared: void
+
+	/**
+	 * Emitted when the current mode changes.
+	 */
+	modeChanged: ModeChangedEvent
+
+	/**
+	 * Emitted on any error during message processing.
+	 */
+	error: Error
+}
+
+/**
+ * Event payload for state changes.
+ */
+export interface AgentStateChangeEvent {
+	/** The previous state info */
+	previousState: AgentStateInfo
+	/** The new/current state info */
+	currentState: AgentStateInfo
+	/** Whether this is a significant state transition (state enum changed) */
+	isSignificantChange: boolean
+}
+
+/**
+ * Event payload when agent starts waiting for input.
+ */
+export interface WaitingForInputEvent {
+	/** The specific ask type */
+	ask: ClineAsk
+	/** Full state info for context */
+	stateInfo: AgentStateInfo
+	/** The message that triggered this wait */
+	message: ClineMessage
+}
+
+/**
+ * Event payload when a task completes.
+ */
+export interface TaskCompletedEvent {
+	/** Whether the task completed successfully */
+	success: boolean
+	/** The final state info */
+	stateInfo: AgentStateInfo
+	/** The completion message if available */
+	message?: ClineMessage
+}
+
+/**
+ * Event payload when mode changes.
+ */
+export interface ModeChangedEvent {
+	/** The previous mode (undefined if first mode set) */
+	previousMode: string | undefined
+	/** The new/current mode */
+	currentMode: string
+}
+
+// =============================================================================
+// Typed Event Emitter
+// =============================================================================
+
+/**
+ * Type-safe event emitter for client events.
+ *
+ * Usage:
+ * ```typescript
+ * const emitter = new TypedEventEmitter()
+ *
+ * // Type-safe subscription
+ * emitter.on('stateChange', (event) => {
+ *   console.log(event.currentState) // TypeScript knows this is AgentStateChangeEvent
+ * })
+ *
+ * // Type-safe emission
+ * emitter.emit('stateChange', { previousState, currentState, isSignificantChange })
+ * ```
+ */
+export class TypedEventEmitter {
+	private emitter = new EventEmitter()
+
+	/**
+	 * Subscribe to an event.
+	 *
+	 * @param event - The event name
+	 * @param listener - The callback function
+	 * @returns Function to unsubscribe
+	 */
+	on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
+		this.emitter.on(event, listener)
+		return () => this.emitter.off(event, listener)
+	}
+
+	/**
+	 * Subscribe to an event, but only once.
+	 *
+	 * @param event - The event name
+	 * @param listener - The callback function
+	 */
+	once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.once(event, listener)
+	}
+
+	/**
+	 * Unsubscribe from an event.
+	 *
+	 * @param event - The event name
+	 * @param listener - The callback function to remove
+	 */
+	off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.off(event, listener)
+	}
+
+	/**
+	 * Emit an event.
+	 *
+	 * @param event - The event name
+	 * @param payload - The event payload
+	 */
+	emit<K extends keyof ClientEventMap>(event: K, payload: ClientEventMap[K]): void {
+		this.emitter.emit(event, payload)
+	}
+
+	/**
+	 * Remove all listeners for an event, or all events.
+	 *
+	 * @param event - Optional event name. If not provided, removes all listeners.
+	 */
+	removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
+		if (event) {
+			this.emitter.removeAllListeners(event)
+		} else {
+			this.emitter.removeAllListeners()
+		}
+	}
+
+	/**
+	 * Get the number of listeners for an event.
+	 */
+	listenerCount<K extends keyof ClientEventMap>(event: K): number {
+		return this.emitter.listenerCount(event)
+	}
+}
+
+// =============================================================================
+// State Change Detector
+// =============================================================================
+
+/**
+ * Helper to determine if a state change is "significant".
+ *
+ * A significant change is when the AgentLoopState enum value changes,
+ * as opposed to just internal state updates within the same state.
+ */
+export function isSignificantStateChange(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return previous.state !== current.state
+}
+
+/**
+ * Helper to determine if we transitioned to waiting for input.
+ */
+export function transitionedToWaiting(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return !previous.isWaitingForInput && current.isWaitingForInput
+}
+
+/**
+ * Helper to determine if we transitioned from waiting to running.
+ */
+export function transitionedToRunning(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return previous.isWaitingForInput && !current.isWaitingForInput && current.isRunning
+}
+
+/**
+ * Helper to determine if streaming started.
+ */
+export function streamingStarted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return !previous.isStreaming && current.isStreaming
+}
+
+/**
+ * Helper to determine if streaming ended.
+ */
+export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	return previous.isStreaming && !current.isStreaming
+}
+
+/**
+ * Helper to determine if task completed.
+ */
+export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
+	const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"]
+	const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
+	const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
+	return wasNotComplete && isNowComplete
+}
+
+// =============================================================================
+// Observable Pattern (Alternative API)
+// =============================================================================
+
+/**
+ * Subscription function type for observable pattern.
+ */
+export type Observer<T> = (value: T) => void
+
+/**
+ * Unsubscribe function type.
+ */
+export type Unsubscribe = () => void
+
+/**
+ * Simple observable for state.
+ *
+ * This provides an alternative to the event emitter pattern
+ * for those who prefer a more functional approach.
+ *
+ * Usage:
+ * ```typescript
+ * const stateObservable = new Observable<AgentStateInfo>()
+ *
+ * const unsubscribe = stateObservable.subscribe((state) => {
+ *   console.log('New state:', state)
+ * })
+ *
+ * // Later...
+ * unsubscribe()
+ * ```
+ */
+export class Observable<T> {
+	private observers: Set<Observer<T>> = new Set()
+	private currentValue: T | undefined
+
+	/**
+	 * Create an observable with an optional initial value.
+	 */
+	constructor(initialValue?: T) {
+		this.currentValue = initialValue
+	}
+
+	/**
+	 * Subscribe to value changes.
+	 *
+	 * @param observer - Function called when value changes
+	 * @returns Unsubscribe function
+	 */
+	subscribe(observer: Observer<T>): Unsubscribe {
+		this.observers.add(observer)
+
+		// Immediately emit current value if we have one
+		if (this.currentValue !== undefined) {
+			observer(this.currentValue)
+		}
+
+		return () => {
+			this.observers.delete(observer)
+		}
+	}
+
+	/**
+	 * Update the value and notify all subscribers.
+	 */
+	next(value: T): void {
+		this.currentValue = value
+		for (const observer of this.observers) {
+			try {
+				observer(value)
+			} catch (error) {
+				console.error("Error in observer:", error)
+			}
+		}
+	}
+
+	/**
+	 * Get the current value without subscribing.
+	 */
+	getValue(): T | undefined {
+		return this.currentValue
+	}
+
+	/**
+	 * Check if there are any subscribers.
+	 */
+	hasSubscribers(): boolean {
+		return this.observers.size > 0
+	}
+
+	/**
+	 * Get the number of subscribers.
+	 */
+	getSubscriberCount(): number {
+		return this.observers.size
+	}
+
+	/**
+	 * Remove all subscribers.
+	 */
+	clear(): void {
+		this.observers.clear()
+	}
+}

+ 580 - 0
apps/cli/src/agent/extension-client.ts

@@ -0,0 +1,580 @@
+/**
+ * Roo Code Client
+ *
+ * This is the main entry point for the client library. It provides a high-level
+ * API for:
+ * - Processing messages from the extension host
+ * - Querying the current agent state
+ * - Subscribing to state change events
+ * - Sending responses back to the extension
+ *
+ * The client is designed to be transport-agnostic. You provide a way to send
+ * messages to the extension, and you feed incoming messages to the client.
+ *
+ * Architecture:
+ * ```
+ *                     ┌───────────────────────────────────────────────┐
+ *                     │               ExtensionClient                 │
+ *                     │                                               │
+ *   Extension ──────▶ │  MessageProcessor ──▶ StateStore              │
+ *   Messages          │         │                  │                  │
+ *                     │         ▼                  ▼                  │
+ *                     │    TypedEventEmitter ◀── State/Events         │
+ *                     │         │                                     │
+ *                     │         ▼                                     │
+ *                     │    Your Event Handlers                        │
+ *                     └───────────────────────────────────────────────┘
+ * ```
+ */
+
+import type { ExtensionMessage, WebviewMessage, ClineAskResponse, ClineMessage, ClineAsk } from "@roo-code/types"
+
+import { StateStore } from "./state-store.js"
+import { MessageProcessor, parseExtensionMessage } from "./message-processor.js"
+import {
+	TypedEventEmitter,
+	type ClientEventMap,
+	type AgentStateChangeEvent,
+	type WaitingForInputEvent,
+	type ModeChangedEvent,
+} from "./events.js"
+import { AgentLoopState, type AgentStateInfo } from "./agent-state.js"
+
+// =============================================================================
+// Extension Client Configuration
+// =============================================================================
+
+/**
+ * Configuration options for the ExtensionClient.
+ */
+export interface ExtensionClientConfig {
+	/**
+	 * Function to send messages to the extension host.
+	 * This is how the client communicates back to the extension.
+	 *
+	 * Example implementations:
+	 * - VSCode webview: (msg) => vscode.postMessage(msg)
+	 * - WebSocket: (msg) => socket.send(JSON.stringify(msg))
+	 * - IPC: (msg) => process.send(msg)
+	 */
+	sendMessage: (message: WebviewMessage) => void
+
+	/**
+	 * Whether to emit events for all state changes or only significant ones.
+	 * Default: true
+	 */
+	emitAllStateChanges?: boolean
+
+	/**
+	 * Enable debug logging.
+	 * Default: false
+	 */
+	debug?: boolean
+
+	/**
+	 * Maximum state history size (for debugging).
+	 * Set to 0 to disable history tracking.
+	 * Default: 0
+	 */
+	maxHistorySize?: number
+}
+
+// =============================================================================
+// Main Client Class
+// =============================================================================
+
+/**
+ * ExtensionClient is the main interface for interacting with the Roo Code extension.
+ *
+ * Basic usage:
+ * ```typescript
+ * // Create client with message sender
+ * const client = new ExtensionClient({
+ *   sendMessage: (msg) => vscode.postMessage(msg)
+ * })
+ *
+ * // Subscribe to state changes
+ * client.on('stateChange', (event) => {
+ *   console.log('State:', event.currentState.state)
+ * })
+ *
+ * // Subscribe to specific events
+ * client.on('waitingForInput', (event) => {
+ *   console.log('Waiting for:', event.ask)
+ * })
+ *
+ * // Feed messages from extension
+ * window.addEventListener('message', (e) => {
+ *   client.handleMessage(e.data)
+ * })
+ *
+ * // Query state at any time
+ * const state = client.getAgentState()
+ * if (state.isWaitingForInput) {
+ *   // Show approval UI
+ * }
+ *
+ * // Send responses
+ * client.approve() // or client.reject() or client.respond('answer')
+ * ```
+ */
+export class ExtensionClient {
+	private store: StateStore
+	private processor: MessageProcessor
+	private emitter: TypedEventEmitter
+	private sendMessage: (message: WebviewMessage) => void
+	private debug: boolean
+
+	constructor(config: ExtensionClientConfig) {
+		this.sendMessage = config.sendMessage
+		this.debug = config.debug ?? false
+		this.store = new StateStore({ maxHistorySize: config.maxHistorySize ?? 0 })
+		this.emitter = new TypedEventEmitter()
+
+		this.processor = new MessageProcessor(this.store, this.emitter, {
+			emitAllStateChanges: config.emitAllStateChanges ?? true,
+			debug: config.debug ?? false,
+		})
+	}
+
+	// ===========================================================================
+	// Message Handling
+	// ===========================================================================
+
+	/**
+	 * Handle an incoming message from the extension host.
+	 *
+	 * Call this method whenever you receive a message from the extension.
+	 * The client will parse, validate, and process the message, updating
+	 * internal state and emitting appropriate events.
+	 *
+	 * @param message - The raw message (can be ExtensionMessage or JSON string)
+	 */
+	handleMessage(message: ExtensionMessage | string): void {
+		let parsed: ExtensionMessage | undefined
+
+		if (typeof message === "string") {
+			parsed = parseExtensionMessage(message)
+
+			if (!parsed) {
+				if (this.debug) {
+					console.log("[ExtensionClient] Failed to parse message:", message)
+				}
+
+				return
+			}
+		} else {
+			parsed = message
+		}
+
+		this.processor.processMessage(parsed)
+	}
+
+	/**
+	 * Handle multiple messages at once.
+	 */
+	handleMessages(messages: (ExtensionMessage | string)[]): void {
+		for (const message of messages) {
+			this.handleMessage(message)
+		}
+	}
+
+	// ===========================================================================
+	// State Queries - Always know the current state
+	// ===========================================================================
+
+	/**
+	 * Get the complete agent state information.
+	 *
+	 * This returns everything you need to know about the current state:
+	 * - The high-level state (running, streaming, waiting, idle, etc.)
+	 * - Whether input is needed
+	 * - The specific ask type if waiting
+	 * - What action is required
+	 * - Human-readable description
+	 */
+	getAgentState(): AgentStateInfo {
+		return this.store.getAgentState()
+	}
+
+	/**
+	 * Get just the current state enum value.
+	 */
+	getCurrentState(): AgentLoopState {
+		return this.store.getCurrentState()
+	}
+
+	/**
+	 * Check if the agent is waiting for user input.
+	 */
+	isWaitingForInput(): boolean {
+		return this.store.isWaitingForInput()
+	}
+
+	/**
+	 * Check if the agent is actively running.
+	 */
+	isRunning(): boolean {
+		return this.store.isRunning()
+	}
+
+	/**
+	 * Check if content is currently streaming.
+	 */
+	isStreaming(): boolean {
+		return this.store.isStreaming()
+	}
+
+	/**
+	 * Check if there is an active task.
+	 */
+	hasActiveTask(): boolean {
+		return this.store.getCurrentState() !== AgentLoopState.NO_TASK
+	}
+
+	/**
+	 * Get all messages in the current task.
+	 */
+	getMessages(): ClineMessage[] {
+		return this.store.getMessages()
+	}
+
+	/**
+	 * Get the last message.
+	 */
+	getLastMessage(): ClineMessage | undefined {
+		return this.store.getLastMessage()
+	}
+
+	/**
+	 * Get the current ask type if the agent is waiting for input.
+	 */
+	getCurrentAsk(): ClineAsk | undefined {
+		return this.store.getAgentState().currentAsk
+	}
+
+	/**
+	 * Check if the client has received any state from the extension.
+	 */
+	isInitialized(): boolean {
+		return this.store.isInitialized()
+	}
+
+	/**
+	 * Get the current mode (e.g., "code", "architect", "ask").
+	 * Returns undefined if no mode has been received yet.
+	 */
+	getCurrentMode(): string | undefined {
+		return this.store.getCurrentMode()
+	}
+
+	// ===========================================================================
+	// Event Subscriptions - Realtime notifications
+	// ===========================================================================
+
+	/**
+	 * Subscribe to an event.
+	 *
+	 * Returns an unsubscribe function for easy cleanup.
+	 *
+	 * @param event - The event to subscribe to
+	 * @param listener - The callback function
+	 * @returns Unsubscribe function
+	 *
+	 * @example
+	 * ```typescript
+	 * const unsubscribe = client.on('stateChange', (event) => {
+	 *   console.log(event.currentState)
+	 * })
+	 *
+	 * // Later, to unsubscribe:
+	 * unsubscribe()
+	 * ```
+	 */
+	on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
+		return this.emitter.on(event, listener)
+	}
+
+	/**
+	 * Subscribe to an event, triggered only once.
+	 */
+	once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.once(event, listener)
+	}
+
+	/**
+	 * Unsubscribe from an event.
+	 */
+	off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
+		this.emitter.off(event, listener)
+	}
+
+	/**
+	 * Remove all listeners for an event, or all events.
+	 */
+	removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
+		this.emitter.removeAllListeners(event)
+	}
+
+	/**
+	 * Convenience method: Subscribe only to state changes.
+	 */
+	onStateChange(listener: (event: AgentStateChangeEvent) => void): () => void {
+		return this.on("stateChange", listener)
+	}
+
+	/**
+	 * Convenience method: Subscribe only to waiting events.
+	 */
+	onWaitingForInput(listener: (event: WaitingForInputEvent) => void): () => void {
+		return this.on("waitingForInput", listener)
+	}
+
+	/**
+	 * Convenience method: Subscribe only to mode changes.
+	 */
+	onModeChanged(listener: (event: ModeChangedEvent) => void): () => void {
+		return this.on("modeChanged", listener)
+	}
+
+	// ===========================================================================
+	// Response Methods - Send actions to the extension
+	// ===========================================================================
+
+	/**
+	 * Approve the current action (tool, command, browser, MCP).
+	 *
+	 * Use when the agent is waiting for approval (interactive asks).
+	 */
+	approve(): void {
+		this.sendResponse("yesButtonClicked")
+	}
+
+	/**
+	 * Reject the current action.
+	 *
+	 * Use when you want to deny a tool, command, or other action.
+	 */
+	reject(): void {
+		this.sendResponse("noButtonClicked")
+	}
+
+	/**
+	 * Send a text response.
+	 *
+	 * Use for:
+	 * - Answering follow-up questions
+	 * - Providing additional context
+	 * - Giving feedback on completion
+	 *
+	 * @param text - The response text
+	 * @param images - Optional base64-encoded images
+	 */
+	respond(text: string, images?: string[]): void {
+		this.sendResponse("messageResponse", text, images)
+	}
+
+	/**
+	 * Generic method to send any ask response.
+	 *
+	 * @param response - The response type
+	 * @param text - Optional text content
+	 * @param images - Optional images
+	 */
+	sendResponse(response: ClineAskResponse, text?: string, images?: string[]): void {
+		const message: WebviewMessage = {
+			type: "askResponse",
+			askResponse: response,
+			text,
+			images,
+		}
+		this.sendMessage(message)
+	}
+
+	// ===========================================================================
+	// Task Control Methods
+	// ===========================================================================
+
+	/**
+	 * Start a new task with the given prompt.
+	 *
+	 * @param text - The task description/prompt
+	 * @param images - Optional base64-encoded images
+	 */
+	newTask(text: string, images?: string[]): void {
+		const message: WebviewMessage = {
+			type: "newTask",
+			text,
+			images,
+		}
+		this.sendMessage(message)
+	}
+
+	/**
+	 * Clear the current task.
+	 *
+	 * This ends the current task and resets to a fresh state.
+	 */
+	clearTask(): void {
+		const message: WebviewMessage = {
+			type: "clearTask",
+		}
+		this.sendMessage(message)
+		this.processor.notifyTaskCleared()
+	}
+
+	/**
+	 * Cancel a running task.
+	 *
+	 * Use this to interrupt a task that is currently processing.
+	 */
+	cancelTask(): void {
+		const message: WebviewMessage = {
+			type: "cancelTask",
+		}
+		this.sendMessage(message)
+	}
+
+	/**
+	 * Resume a paused task.
+	 *
+	 * Use when the agent state is RESUMABLE (resume_task ask).
+	 */
+	resumeTask(): void {
+		this.approve() // Resume uses the same response as approve
+	}
+
+	/**
+	 * Retry a failed API request.
+	 *
+	 * Use when the agent state shows api_req_failed.
+	 */
+	retryApiRequest(): void {
+		this.approve() // Retry uses the same response as approve
+	}
+
+	// ===========================================================================
+	// Terminal Operation Methods
+	// ===========================================================================
+
+	/**
+	 * Continue terminal output (don't wait for more output).
+	 *
+	 * Use when the agent is showing command_output and you want to proceed.
+	 */
+	continueTerminal(): void {
+		const message: WebviewMessage = {
+			type: "terminalOperation",
+			terminalOperation: "continue",
+		}
+		this.sendMessage(message)
+	}
+
+	/**
+	 * Abort terminal command.
+	 *
+	 * Use when you want to kill a running terminal command.
+	 */
+	abortTerminal(): void {
+		const message: WebviewMessage = {
+			type: "terminalOperation",
+			terminalOperation: "abort",
+		}
+		this.sendMessage(message)
+	}
+
+	// ===========================================================================
+	// Utility Methods
+	// ===========================================================================
+
+	/**
+	 * Reset the client state.
+	 *
+	 * This clears all internal state and history.
+	 * Useful when disconnecting or starting fresh.
+	 */
+	reset(): void {
+		this.store.reset()
+		this.emitter.removeAllListeners()
+	}
+
+	/**
+	 * Get the state history (if history tracking is enabled).
+	 */
+	getStateHistory() {
+		return this.store.getHistory()
+	}
+
+	/**
+	 * Enable or disable debug mode.
+	 */
+	setDebug(enabled: boolean): void {
+		this.debug = enabled
+		this.processor.setDebug(enabled)
+	}
+
+	// ===========================================================================
+	// Advanced: Direct Store Access
+	// ===========================================================================
+
+	/**
+	 * Get direct access to the state store.
+	 *
+	 * This is for advanced use cases where you need more control.
+	 * Most users should use the methods above instead.
+	 */
+	getStore(): StateStore {
+		return this.store
+	}
+
+	/**
+	 * Get direct access to the event emitter.
+	 */
+	getEmitter(): TypedEventEmitter {
+		return this.emitter
+	}
+}
+
+// =============================================================================
+// Factory Functions
+// =============================================================================
+
+/**
+ * Create a new ExtensionClient instance.
+ *
+ * This is a convenience function that creates a client with default settings.
+ *
+ * @param sendMessage - Function to send messages to the extension
+ * @returns A new ExtensionClient instance
+ */
+export function createClient(sendMessage: (message: WebviewMessage) => void): ExtensionClient {
+	return new ExtensionClient({ sendMessage })
+}
+
+/**
+ * Create a mock client for testing.
+ *
+ * The mock client captures all sent messages for verification.
+ *
+ * @returns An object with the client and captured messages
+ */
+export function createMockClient(): {
+	client: ExtensionClient
+	sentMessages: WebviewMessage[]
+	clearMessages: () => void
+} {
+	const sentMessages: WebviewMessage[] = []
+
+	const client = new ExtensionClient({
+		sendMessage: (message) => sentMessages.push(message),
+		debug: false,
+	})
+
+	return {
+		client,
+		sentMessages,
+		clearMessages: () => {
+			sentMessages.length = 0
+		},
+	}
+}

+ 542 - 0
apps/cli/src/agent/extension-host.ts

@@ -0,0 +1,542 @@
+/**
+ * ExtensionHost - Loads and runs the Roo Code extension in CLI mode
+ *
+ * This class is a thin coordination layer responsible for:
+ * 1. Creating the vscode-shim mock
+ * 2. Loading the extension bundle via require()
+ * 3. Activating the extension
+ * 4. Wiring up managers for output, prompting, and ask handling
+ */
+
+import { createRequire } from "module"
+import path from "path"
+import { fileURLToPath } from "url"
+import fs from "fs"
+import { EventEmitter } from "events"
+
+import pWaitFor from "p-wait-for"
+
+import type {
+	ClineMessage,
+	ExtensionMessage,
+	ReasoningEffortExtended,
+	RooCodeSettings,
+	WebviewMessage,
+} from "@roo-code/types"
+import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
+import { DebugLogger } from "@roo-code/core/cli"
+
+import type { SupportedProvider } from "@/types/index.js"
+import type { User } from "@/lib/sdk/index.js"
+import { getProviderSettings } from "@/lib/utils/provider.js"
+import { createEphemeralStorageDir } from "@/lib/storage/index.js"
+
+import type { WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
+import type { AgentStateInfo } from "./agent-state.js"
+import { ExtensionClient } from "./extension-client.js"
+import { OutputManager } from "./output-manager.js"
+import { PromptManager } from "./prompt-manager.js"
+import { AskDispatcher } from "./ask-dispatcher.js"
+
+// Pre-configured logger for CLI message activity debugging.
+const cliLogger = new DebugLogger("CLI")
+
+// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
+// When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script.
+// In development, we fall back to calculating from __dirname.
+// After bundling with tsup, the code is in dist/index.js (flat), so we go up one level.
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..")
+
+export interface ExtensionHostOptions {
+	mode: string
+	reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
+	user: User | null
+	provider: SupportedProvider
+	apiKey?: string
+	model: string
+	workspacePath: string
+	extensionPath: string
+	nonInteractive?: boolean
+	debug?: boolean
+	/**
+	 * When true, completely disables all direct stdout/stderr output.
+	 * Use this when running in TUI mode where Ink controls the terminal.
+	 */
+	disableOutput?: boolean
+	/**
+	 * When true, uses a temporary storage directory that is cleaned up on exit.
+	 */
+	ephemeral?: boolean
+	/**
+	 * When true, don't suppress node warnings and console output since we're
+	 * running in an integration test and we want to see the output.
+	 */
+	integrationTest?: boolean
+}
+
+interface ExtensionModule {
+	activate: (context: unknown) => Promise<unknown>
+	deactivate?: () => Promise<void>
+}
+
+interface WebviewViewProvider {
+	resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
+}
+
+export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
+	client: ExtensionClient
+	activate(): Promise<void>
+	runTask(prompt: string): Promise<void>
+	sendToExtension(message: WebviewMessage): void
+	dispose(): Promise<void>
+}
+
+export class ExtensionHost extends EventEmitter implements ExtensionHostInterface {
+	// Extension lifecycle.
+	private vscode: ReturnType<typeof createVSCodeAPI> | null = null
+	private extensionModule: ExtensionModule | null = null
+	private extensionAPI: unknown = null
+	private options: ExtensionHostOptions
+	private isReady = false
+	private messageListener: ((message: ExtensionMessage) => void) | null = null
+	private initialSettings: RooCodeSettings
+
+	// Console suppression.
+	private originalConsole: {
+		log: typeof console.log
+		warn: typeof console.warn
+		error: typeof console.error
+		debug: typeof console.debug
+		info: typeof console.info
+	} | null = null
+
+	private originalProcessEmitWarning: typeof process.emitWarning | null = null
+
+	// Ephemeral storage.
+	private ephemeralStorageDir: string | null = null
+
+	// ==========================================================================
+	// Managers - These do all the heavy lifting
+	// ==========================================================================
+
+	/**
+	 * ExtensionClient: Single source of truth for agent loop state.
+	 * Handles message processing and state detection.
+	 */
+	public readonly client: ExtensionClient
+
+	/**
+	 * OutputManager: Handles all CLI output and streaming.
+	 * Uses Observable pattern internally for stream tracking.
+	 */
+	private outputManager: OutputManager
+
+	/**
+	 * PromptManager: Handles all user input collection.
+	 * Provides readline, yes/no, and timed prompts.
+	 */
+	private promptManager: PromptManager
+
+	/**
+	 * AskDispatcher: Routes asks to appropriate handlers.
+	 * Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module.
+	 */
+	private askDispatcher: AskDispatcher
+
+	// ==========================================================================
+	// Constructor
+	// ==========================================================================
+
+	constructor(options: ExtensionHostOptions) {
+		super()
+
+		this.options = options
+		this.options.integrationTest = true
+
+		// Initialize client - single source of truth for agent state (including mode).
+		this.client = new ExtensionClient({
+			sendMessage: (msg) => this.sendToExtension(msg),
+			debug: options.debug, // Enable debug logging in the client.
+		})
+
+		// Initialize output manager.
+		this.outputManager = new OutputManager({
+			disabled: options.disableOutput,
+		})
+
+		// Initialize prompt manager with console mode callbacks.
+		this.promptManager = new PromptManager({
+			onBeforePrompt: () => this.restoreConsole(),
+			onAfterPrompt: () => this.setupQuietMode(),
+		})
+
+		// Initialize ask dispatcher.
+		this.askDispatcher = new AskDispatcher({
+			outputManager: this.outputManager,
+			promptManager: this.promptManager,
+			sendMessage: (msg) => this.sendToExtension(msg),
+			nonInteractive: options.nonInteractive,
+			disabled: options.disableOutput, // TUI mode handles asks directly.
+		})
+
+		// Wire up client events.
+		this.setupClientEventHandlers()
+
+		// Populate initial settings.
+		const baseSettings: RooCodeSettings = {
+			mode: this.options.mode,
+			commandExecutionTimeout: 30,
+			browserToolEnabled: false,
+			enableCheckpoints: false,
+			...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
+		}
+
+		this.initialSettings = this.options.nonInteractive
+			? {
+					autoApprovalEnabled: true,
+					alwaysAllowReadOnly: true,
+					alwaysAllowReadOnlyOutsideWorkspace: true,
+					alwaysAllowWrite: true,
+					alwaysAllowWriteOutsideWorkspace: true,
+					alwaysAllowWriteProtected: true,
+					alwaysAllowBrowser: true,
+					alwaysAllowMcp: true,
+					alwaysAllowModeSwitch: true,
+					alwaysAllowSubtasks: true,
+					alwaysAllowExecute: true,
+					allowedCommands: ["*"],
+					...baseSettings,
+				}
+			: {
+					autoApprovalEnabled: false,
+					...baseSettings,
+				}
+
+		if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
+			if (this.options.reasoningEffort === "disabled") {
+				this.initialSettings.enableReasoningEffort = false
+			} else {
+				this.initialSettings.enableReasoningEffort = true
+				this.initialSettings.reasoningEffort = this.options.reasoningEffort
+			}
+		}
+
+		this.setupQuietMode()
+	}
+
+	// ==========================================================================
+	// Client Event Handlers
+	// ==========================================================================
+
+	/**
+	 * Wire up client events to managers.
+	 * The client emits events, managers handle them.
+	 */
+	private setupClientEventHandlers(): void {
+		// Handle new messages - delegate to OutputManager.
+		this.client.on("message", (msg: ClineMessage) => {
+			this.logMessageDebug(msg, "new")
+			this.outputManager.outputMessage(msg)
+		})
+
+		// Handle message updates - delegate to OutputManager.
+		this.client.on("messageUpdated", (msg: ClineMessage) => {
+			this.logMessageDebug(msg, "updated")
+			this.outputManager.outputMessage(msg)
+		})
+
+		// Handle waiting for input - delegate to AskDispatcher.
+		this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
+			this.askDispatcher.handleAsk(event.message)
+		})
+
+		// Handle task completion.
+		this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
+			// Output completion message via OutputManager.
+			// Note: completion_result is an "ask" type, not a "say" type.
+			if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
+				this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
+			}
+		})
+	}
+
+	// ==========================================================================
+	// Logging + Console Suppression
+	// ==========================================================================
+
+	private setupQuietMode(): void {
+		if (this.options.integrationTest) {
+			return
+		}
+
+		// Suppress node warnings.
+		this.originalProcessEmitWarning = process.emitWarning
+		process.emitWarning = () => {}
+		process.on("warning", () => {})
+
+		// Suppress console output.
+		this.originalConsole = {
+			log: console.log,
+			warn: console.warn,
+			error: console.error,
+			debug: console.debug,
+			info: console.info,
+		}
+
+		console.log = () => {}
+		console.warn = () => {}
+		console.debug = () => {}
+		console.info = () => {}
+	}
+
+	private restoreConsole(): void {
+		if (this.options.integrationTest) {
+			return
+		}
+
+		if (this.originalConsole) {
+			console.log = this.originalConsole.log
+			console.warn = this.originalConsole.warn
+			console.error = this.originalConsole.error
+			console.debug = this.originalConsole.debug
+			console.info = this.originalConsole.info
+			this.originalConsole = null
+		}
+
+		if (this.originalProcessEmitWarning) {
+			process.emitWarning = this.originalProcessEmitWarning
+			this.originalProcessEmitWarning = null
+		}
+	}
+
+	private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
+		if (msg.partial) {
+			if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
+				this.outputManager.setLoggedFirstPartial(msg.ts)
+				cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
+			}
+		} else {
+			cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
+			this.outputManager.clearLoggedFirstPartial(msg.ts)
+		}
+	}
+
+	// ==========================================================================
+	// Extension Lifecycle
+	// ==========================================================================
+
+	public async activate(): Promise<void> {
+		const bundlePath = path.join(this.options.extensionPath, "extension.js")
+
+		if (!fs.existsSync(bundlePath)) {
+			this.restoreConsole()
+			throw new Error(`Extension bundle not found at: ${bundlePath}`)
+		}
+
+		let storageDir: string | undefined
+
+		if (this.options.ephemeral) {
+			this.ephemeralStorageDir = await createEphemeralStorageDir()
+			storageDir = this.ephemeralStorageDir
+		}
+
+		// Create VSCode API mock.
+		this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
+			appRoot: CLI_PACKAGE_ROOT,
+			storageDir,
+		})
+		;(global as Record<string, unknown>).vscode = this.vscode
+		;(global as Record<string, unknown>).__extensionHost = this
+
+		// Set up module resolution.
+		const require = createRequire(import.meta.url)
+		const Module = require("module")
+		const originalResolve = Module._resolveFilename
+
+		Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) {
+			if (request === "vscode") return "vscode-mock"
+			return originalResolve.call(this, request, parent, isMain, options)
+		}
+
+		require.cache["vscode-mock"] = {
+			id: "vscode-mock",
+			filename: "vscode-mock",
+			loaded: true,
+			exports: this.vscode,
+			children: [],
+			paths: [],
+			path: "",
+			isPreloading: false,
+			parent: null,
+			require: require,
+		} as unknown as NodeJS.Module
+
+		try {
+			this.extensionModule = require(bundlePath) as ExtensionModule
+		} catch (error) {
+			Module._resolveFilename = originalResolve
+
+			throw new Error(
+				`Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
+			)
+		}
+
+		Module._resolveFilename = originalResolve
+
+		try {
+			this.extensionAPI = await this.extensionModule.activate(this.vscode.context)
+		} catch (error) {
+			throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
+		}
+
+		// Set up message listener - forward all messages to client.
+		this.messageListener = (message: ExtensionMessage) => this.client.handleMessage(message)
+		this.on("extensionWebviewMessage", this.messageListener)
+
+		await pWaitFor(() => this.isReady, { interval: 100, timeout: 10_000 })
+	}
+
+	public registerWebviewProvider(_viewId: string, _provider: WebviewViewProvider): void {}
+
+	public unregisterWebviewProvider(_viewId: string): void {}
+
+	public markWebviewReady(): void {
+		this.isReady = true
+
+		// Send initial webview messages to trigger proper extension initialization.
+		// This is critical for the extension to start sending state updates properly.
+		this.sendToExtension({ type: "webviewDidLaunch" })
+
+		setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
+		this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
+	}
+
+	public isInInitialSetup(): boolean {
+		return !this.isReady
+	}
+
+	// ==========================================================================
+	// Message Handling
+	// ==========================================================================
+
+	public sendToExtension(message: WebviewMessage): void {
+		if (!this.isReady) {
+			throw new Error("You cannot send messages to the extension before it is ready")
+		}
+
+		this.emit("webviewMessage", message)
+	}
+
+	// ==========================================================================
+	// Task Management
+	// ==========================================================================
+
+	public async runTask(prompt: string): Promise<void> {
+		this.sendToExtension({ type: "newTask", text: prompt })
+
+		return new Promise((resolve, reject) => {
+			let timeoutId: NodeJS.Timeout | null = null
+			const timeoutMs: number = 110_000
+
+			const completeHandler = () => {
+				cleanup()
+				resolve()
+			}
+
+			const errorHandler = (error: Error) => {
+				cleanup()
+				reject(error)
+			}
+
+			const cleanup = () => {
+				if (timeoutId) {
+					clearTimeout(timeoutId)
+					timeoutId = null
+				}
+
+				this.client.off("taskCompleted", completeHandler)
+				this.client.off("error", errorHandler)
+			}
+
+			// Set timeout to prevent indefinite hanging.
+			timeoutId = setTimeout(() => {
+				cleanup()
+				reject(
+					new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
+				)
+			}, timeoutMs)
+
+			this.client.once("taskCompleted", completeHandler)
+			this.client.once("error", errorHandler)
+		})
+	}
+
+	// ==========================================================================
+	// Public Agent State API
+	// ==========================================================================
+
+	/**
+	 * Get the current agent loop state.
+	 */
+	public getAgentState(): AgentStateInfo {
+		return this.client.getAgentState()
+	}
+
+	/**
+	 * Check if the agent is currently waiting for user input.
+	 */
+	public isWaitingForInput(): boolean {
+		return this.client.getAgentState().isWaitingForInput
+	}
+
+	// ==========================================================================
+	// Cleanup
+	// ==========================================================================
+
+	async dispose(): Promise<void> {
+		// Clear managers.
+		this.outputManager.clear()
+		this.askDispatcher.clear()
+
+		// Remove message listener.
+		if (this.messageListener) {
+			this.off("extensionWebviewMessage", this.messageListener)
+			this.messageListener = null
+		}
+
+		// Reset client.
+		this.client.reset()
+
+		// Deactivate extension.
+		if (this.extensionModule?.deactivate) {
+			try {
+				await this.extensionModule.deactivate()
+			} catch {
+				// NO-OP
+			}
+		}
+
+		// Clear references.
+		this.vscode = null
+		this.extensionModule = null
+		this.extensionAPI = null
+
+		// Clear globals.
+		delete (global as Record<string, unknown>).vscode
+		delete (global as Record<string, unknown>).__extensionHost
+
+		// Restore console.
+		this.restoreConsole()
+
+		// Clean up ephemeral storage.
+		if (this.ephemeralStorageDir) {
+			try {
+				await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
+				this.ephemeralStorageDir = null
+			} catch {
+				// NO-OP
+			}
+		}
+	}
+}

+ 1 - 0
apps/cli/src/agent/index.ts

@@ -0,0 +1 @@
+export * from "./extension-host.js"

+ 479 - 0
apps/cli/src/agent/message-processor.ts

@@ -0,0 +1,479 @@
+/**
+ * Message Processor
+ *
+ * This module handles incoming messages from the extension host and dispatches
+ * appropriate state updates and events. It acts as the bridge between raw
+ * extension messages and the client's internal state management.
+ *
+ * Message Flow:
+ * ```
+ * Extension Host ──▶ MessageProcessor ──▶ StateStore ──▶ Events
+ * ```
+ *
+ * The processor handles different message types:
+ * - "state": Full state update from extension
+ * - "messageUpdated": Single message update
+ * - "action": UI action triggers
+ * - "invoke": Command invocations
+ */
+
+import { ExtensionMessage, ClineMessage } from "@roo-code/types"
+import { debugLog } from "@roo-code/core/cli"
+
+import type { StateStore } from "./state-store.js"
+import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
+import {
+	isSignificantStateChange,
+	transitionedToWaiting,
+	transitionedToRunning,
+	streamingStarted,
+	streamingEnded,
+	taskCompleted,
+} from "./events.js"
+import type { AgentStateInfo } from "./agent-state.js"
+
+// =============================================================================
+// Message Processor Options
+// =============================================================================
+
+export interface MessageProcessorOptions {
+	/**
+	 * Whether to emit events for every state change, or only significant ones.
+	 * Default: true (emit all changes)
+	 */
+	emitAllStateChanges?: boolean
+
+	/**
+	 * Whether to log debug information.
+	 * Default: false
+	 */
+	debug?: boolean
+}
+
+// =============================================================================
+// Message Processor Class
+// =============================================================================
+
+/**
+ * MessageProcessor handles incoming extension messages and updates state accordingly.
+ *
+ * It is responsible for:
+ * 1. Parsing and validating incoming messages
+ * 2. Updating the state store
+ * 3. Emitting appropriate events
+ *
+ * Usage:
+ * ```typescript
+ * const store = new StateStore()
+ * const emitter = new TypedEventEmitter()
+ * const processor = new MessageProcessor(store, emitter)
+ *
+ * // Process a message from the extension
+ * processor.processMessage(extensionMessage)
+ * ```
+ */
+export class MessageProcessor {
+	private store: StateStore
+	private emitter: TypedEventEmitter
+	private options: Required<MessageProcessorOptions>
+
+	constructor(store: StateStore, emitter: TypedEventEmitter, options: MessageProcessorOptions = {}) {
+		this.store = store
+		this.emitter = emitter
+		this.options = {
+			emitAllStateChanges: options.emitAllStateChanges ?? true,
+			debug: options.debug ?? false,
+		}
+	}
+
+	// ===========================================================================
+	// Main Processing Methods
+	// ===========================================================================
+
+	/**
+	 * Process an incoming message from the extension host.
+	 *
+	 * This is the main entry point for all extension messages.
+	 * It routes messages to the appropriate handler based on type.
+	 *
+	 * @param message - The raw message from the extension
+	 */
+	processMessage(message: ExtensionMessage): void {
+		if (this.options.debug) {
+			debugLog("[MessageProcessor] Received message", { type: message.type })
+		}
+
+		try {
+			switch (message.type) {
+				case "state":
+					this.handleStateMessage(message)
+					break
+
+				case "messageUpdated":
+					this.handleMessageUpdated(message)
+					break
+
+				case "action":
+					this.handleAction(message)
+					break
+
+				case "invoke":
+					this.handleInvoke(message)
+					break
+
+				default:
+					// Other message types are not relevant to state detection
+					if (this.options.debug) {
+						debugLog("[MessageProcessor] Ignoring message", { type: message.type })
+					}
+			}
+		} catch (error) {
+			const err = error instanceof Error ? error : new Error(String(error))
+			debugLog("[MessageProcessor] Error processing message", { error: err.message })
+			this.emitter.emit("error", err)
+		}
+	}
+
+	/**
+	 * Process an array of messages (for batch updates).
+	 */
+	processMessages(messages: ExtensionMessage[]): void {
+		for (const message of messages) {
+			this.processMessage(message)
+		}
+	}
+
+	// ===========================================================================
+	// Message Type Handlers
+	// ===========================================================================
+
+	/**
+	 * Handle a "state" message - full state update from extension.
+	 *
+	 * This is the most important message type for state detection.
+	 * It contains the complete clineMessages array which is the source of truth.
+	 */
+	private handleStateMessage(message: ExtensionMessage): void {
+		if (!message.state) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] State message missing state payload")
+			}
+			return
+		}
+
+		const { clineMessages, mode } = message.state
+
+		// Track mode changes.
+		if (mode && typeof mode === "string") {
+			const previousMode = this.store.getCurrentMode()
+
+			if (previousMode !== mode) {
+				if (this.options.debug) {
+					debugLog("[MessageProcessor] Mode changed", { from: previousMode, to: mode })
+				}
+
+				this.store.setCurrentMode(mode)
+				this.emitter.emit("modeChanged", { previousMode, currentMode: mode })
+			}
+		}
+
+		if (!clineMessages) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] State message missing clineMessages")
+			}
+			return
+		}
+
+		// Get previous state for comparison.
+		const previousState = this.store.getAgentState()
+
+		// Update the store with new messages
+		// Note: We only call setMessages, NOT setExtensionState, to avoid
+		// double processing (setExtensionState would call setMessages again)
+		this.store.setMessages(clineMessages)
+
+		// Get new state after update
+		const currentState = this.store.getAgentState()
+
+		// Debug logging for state message
+		if (this.options.debug) {
+			const lastMsg = clineMessages[clineMessages.length - 1]
+			const lastMsgInfo = lastMsg
+				? {
+						msgType: lastMsg.type === "ask" ? `ask:${lastMsg.ask}` : `say:${lastMsg.say}`,
+						partial: lastMsg.partial,
+						textPreview: lastMsg.text?.substring(0, 50),
+					}
+				: null
+			debugLog("[MessageProcessor] State update", {
+				messageCount: clineMessages.length,
+				lastMessage: lastMsgInfo,
+				stateTransition: `${previousState.state} → ${currentState.state}`,
+				currentAsk: currentState.currentAsk,
+				isWaitingForInput: currentState.isWaitingForInput,
+				isStreaming: currentState.isStreaming,
+				isRunning: currentState.isRunning,
+			})
+		}
+
+		// Emit events based on state changes
+		this.emitStateChangeEvents(previousState, currentState)
+
+		// Emit new message events for any messages we haven't seen
+		this.emitNewMessageEvents(previousState, currentState, clineMessages)
+	}
+
+	/**
+	 * Handle a "messageUpdated" message - single message update.
+	 *
+	 * This is sent when a message is modified (e.g., partial -> complete).
+	 */
+	private handleMessageUpdated(message: ExtensionMessage): void {
+		if (!message.clineMessage) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] messageUpdated missing clineMessage")
+			}
+			return
+		}
+
+		const clineMessage = message.clineMessage
+		const previousState = this.store.getAgentState()
+
+		// Update the message in the store
+		this.store.updateMessage(clineMessage)
+
+		const currentState = this.store.getAgentState()
+
+		// Emit message updated event
+		this.emitter.emit("messageUpdated", clineMessage)
+
+		// Emit state change events
+		this.emitStateChangeEvents(previousState, currentState)
+	}
+
+	/**
+	 * Handle an "action" message - UI action trigger.
+	 *
+	 * These are typically used to trigger UI behaviors and don't
+	 * directly affect agent state, but we can track them if needed.
+	 */
+	private handleAction(message: ExtensionMessage): void {
+		if (this.options.debug) {
+			debugLog("[MessageProcessor] Action", { action: message.action })
+		}
+		// Actions don't affect agent state, but subclasses could override this
+	}
+
+	/**
+	 * Handle an "invoke" message - command invocation.
+	 *
+	 * These are commands that should trigger specific behaviors.
+	 */
+	private handleInvoke(message: ExtensionMessage): void {
+		if (this.options.debug) {
+			debugLog("[MessageProcessor] Invoke", { invoke: message.invoke })
+		}
+		// Invokes don't directly affect state detection
+		// But they might trigger state changes through subsequent messages
+	}
+
+	// ===========================================================================
+	// Event Emission Helpers
+	// ===========================================================================
+
+	/**
+	 * Emit events based on state changes.
+	 */
+	private emitStateChangeEvents(previousState: AgentStateInfo, currentState: AgentStateInfo): void {
+		const isSignificant = isSignificantStateChange(previousState, currentState)
+
+		// Emit stateChange event
+		if (this.options.emitAllStateChanges || isSignificant) {
+			const changeEvent: AgentStateChangeEvent = {
+				previousState,
+				currentState,
+				isSignificantChange: isSignificant,
+			}
+			this.emitter.emit("stateChange", changeEvent)
+		}
+
+		// Emit specific transition events
+
+		// Waiting for input
+		if (transitionedToWaiting(previousState, currentState)) {
+			if (currentState.currentAsk && currentState.lastMessage) {
+				if (this.options.debug) {
+					debugLog("[MessageProcessor] EMIT waitingForInput", {
+						ask: currentState.currentAsk,
+						action: currentState.requiredAction,
+					})
+				}
+				const waitingEvent: WaitingForInputEvent = {
+					ask: currentState.currentAsk,
+					stateInfo: currentState,
+					message: currentState.lastMessage,
+				}
+				this.emitter.emit("waitingForInput", waitingEvent)
+			}
+		}
+
+		// Resumed running
+		if (transitionedToRunning(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT resumedRunning")
+			}
+			this.emitter.emit("resumedRunning", undefined as void)
+		}
+
+		// Streaming started
+		if (streamingStarted(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT streamingStarted")
+			}
+			this.emitter.emit("streamingStarted", undefined as void)
+		}
+
+		// Streaming ended
+		if (streamingEnded(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT streamingEnded")
+			}
+			this.emitter.emit("streamingEnded", undefined as void)
+		}
+
+		// Task completed
+		if (taskCompleted(previousState, currentState)) {
+			if (this.options.debug) {
+				debugLog("[MessageProcessor] EMIT taskCompleted", {
+					success: currentState.currentAsk === "completion_result",
+				})
+			}
+			const completedEvent: TaskCompletedEvent = {
+				success: currentState.currentAsk === "completion_result",
+				stateInfo: currentState,
+				message: currentState.lastMessage,
+			}
+			this.emitter.emit("taskCompleted", completedEvent)
+		}
+	}
+
+	/**
+	 * Emit events for new messages.
+	 *
+	 * We compare the previous and current message counts to find new messages.
+	 * This is a simple heuristic - for more accuracy, we'd track by timestamp.
+	 */
+	private emitNewMessageEvents(
+		_previousState: AgentStateInfo,
+		_currentState: AgentStateInfo,
+		messages: ClineMessage[],
+	): void {
+		// For now, just emit the last message as new
+		// A more sophisticated implementation would track seen message timestamps
+		const lastMessage = messages[messages.length - 1]
+		if (lastMessage) {
+			this.emitter.emit("message", lastMessage)
+		}
+	}
+
+	// ===========================================================================
+	// Utility Methods
+	// ===========================================================================
+
+	/**
+	 * Manually trigger a task cleared event.
+	 * Call this when you send a clearTask message to the extension.
+	 */
+	notifyTaskCleared(): void {
+		this.store.clear()
+		this.emitter.emit("taskCleared", undefined as void)
+	}
+
+	/**
+	 * Enable or disable debug logging.
+	 */
+	setDebug(enabled: boolean): void {
+		this.options.debug = enabled
+	}
+}
+
+// =============================================================================
+// Message Validation Helpers
+// =============================================================================
+
+/**
+ * Check if a message is a valid ClineMessage.
+ * Useful for validating messages before processing.
+ */
+export function isValidClineMessage(message: unknown): message is ClineMessage {
+	if (!message || typeof message !== "object") {
+		return false
+	}
+
+	const msg = message as Record<string, unknown>
+
+	// Required fields
+	if (typeof msg.ts !== "number") {
+		return false
+	}
+
+	if (msg.type !== "ask" && msg.type !== "say") {
+		return false
+	}
+
+	return true
+}
+
+/**
+ * Check if a message is a valid ExtensionMessage.
+ */
+export function isValidExtensionMessage(message: unknown): message is ExtensionMessage {
+	if (!message || typeof message !== "object") {
+		return false
+	}
+
+	const msg = message as Record<string, unknown>
+
+	// Must have a type
+	if (typeof msg.type !== "string") {
+		return false
+	}
+
+	return true
+}
+
+// =============================================================================
+// Message Parsing Utilities
+// =============================================================================
+
+/**
+ * Parse a JSON string into an ExtensionMessage.
+ * Returns undefined if parsing fails.
+ */
+export function parseExtensionMessage(json: string): ExtensionMessage | undefined {
+	try {
+		const parsed = JSON.parse(json)
+		if (isValidExtensionMessage(parsed)) {
+			return parsed
+		}
+		return undefined
+	} catch {
+		return undefined
+	}
+}
+
+/**
+ * Parse the text field of an api_req_started message.
+ * Returns undefined if parsing fails or text is not present.
+ */
+export function parseApiReqStartedText(message: ClineMessage): { cost?: number } | undefined {
+	if (message.say !== "api_req_started" || !message.text) {
+		return undefined
+	}
+
+	try {
+		return JSON.parse(message.text)
+	} catch {
+		return undefined
+	}
+}

+ 414 - 0
apps/cli/src/agent/output-manager.ts

@@ -0,0 +1,414 @@
+/**
+ * OutputManager - Handles all CLI output and streaming
+ *
+ * This manager is responsible for:
+ * - Writing messages to stdout/stderr
+ * - Tracking what's been displayed (to avoid duplicates)
+ * - Managing streaming content with delta computation
+ * - Formatting different message types appropriately
+ *
+ * Design notes:
+ * - Uses the Observable pattern from client/events.ts for internal state
+ * - Single responsibility: CLI output only (no prompting, no state detection)
+ * - Can be disabled for TUI mode where Ink controls the terminal
+ */
+
+import { ClineMessage, ClineSay } from "@roo-code/types"
+
+import { Observable } from "./events.js"
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Tracks what we've displayed for a specific message ts.
+ */
+export interface DisplayedMessage {
+	ts: number
+	text: string
+	partial: boolean
+}
+
+/**
+ * Tracks streaming state for a message.
+ */
+export interface StreamState {
+	ts: number
+	text: string
+	headerShown: boolean
+}
+
+/**
+ * Configuration options for OutputManager.
+ */
+export interface OutputManagerOptions {
+	/**
+	 * When true, completely disables all output.
+	 * Use for TUI mode where another system controls the terminal.
+	 */
+	disabled?: boolean
+
+	/**
+	 * Stream for normal output (default: process.stdout).
+	 */
+	stdout?: NodeJS.WriteStream
+
+	/**
+	 * Stream for error output (default: process.stderr).
+	 */
+	stderr?: NodeJS.WriteStream
+}
+
+// =============================================================================
+// OutputManager Class
+// =============================================================================
+
+export class OutputManager {
+	private disabled: boolean
+	private stdout: NodeJS.WriteStream
+	private stderr: NodeJS.WriteStream
+
+	/**
+	 * Track displayed messages by ts to avoid duplicate output.
+	 * Observable pattern allows external systems to subscribe if needed.
+	 */
+	private displayedMessages = new Map<number, DisplayedMessage>()
+
+	/**
+	 * Track streamed content by ts for delta computation.
+	 */
+	private streamedContent = new Map<number, StreamState>()
+
+	/**
+	 * Track which ts is currently streaming (for newline management).
+	 */
+	private currentlyStreamingTs: number | null = null
+
+	/**
+	 * Track first partial logs (for debugging first/last pattern).
+	 */
+	private loggedFirstPartial = new Set<number>()
+
+	/**
+	 * Observable for streaming state changes.
+	 * External systems can subscribe to know when streaming starts/ends.
+	 */
+	public readonly streamingState = new Observable<{ ts: number | null; isStreaming: boolean }>({
+		ts: null,
+		isStreaming: false,
+	})
+
+	constructor(options: OutputManagerOptions = {}) {
+		this.disabled = options.disabled ?? false
+		this.stdout = options.stdout ?? process.stdout
+		this.stderr = options.stderr ?? process.stderr
+	}
+
+	// ===========================================================================
+	// Public API
+	// ===========================================================================
+
+	/**
+	 * Output a ClineMessage based on its type.
+	 * This is the main entry point for message output.
+	 *
+	 * @param msg - The message to output
+	 * @param skipFirstUserMessage - If true, skip the first "text" message (user prompt echo)
+	 */
+	outputMessage(msg: ClineMessage, skipFirstUserMessage = true): void {
+		const ts = msg.ts
+		const text = msg.text || ""
+		const isPartial = msg.partial === true
+		const previousDisplay = this.displayedMessages.get(ts)
+		const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial
+
+		if (msg.type === "say" && msg.say) {
+			this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
+		} else if (msg.type === "ask" && msg.ask) {
+			// For ask messages, we only output command_output here
+			// Other asks are handled by AskDispatcher
+			if (msg.ask === "command_output") {
+				this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
+			}
+		}
+	}
+
+	/**
+	 * Output a simple text line with a label.
+	 */
+	output(label: string, text?: string): void {
+		if (this.disabled) return
+		const message = text ? `${label} ${text}\n` : `${label}\n`
+		this.stdout.write(message)
+	}
+
+	/**
+	 * Output an error message.
+	 */
+	outputError(label: string, text?: string): void {
+		if (this.disabled) return
+		const message = text ? `${label} ${text}\n` : `${label}\n`
+		this.stderr.write(message)
+	}
+
+	/**
+	 * Write raw text to stdout (for streaming).
+	 */
+	writeRaw(text: string): void {
+		if (this.disabled) return
+		this.stdout.write(text)
+	}
+
+	/**
+	 * Check if a message has already been fully displayed.
+	 */
+	isAlreadyDisplayed(ts: number): boolean {
+		const displayed = this.displayedMessages.get(ts)
+		return displayed !== undefined && !displayed.partial
+	}
+
+	/**
+	 * Check if we're currently streaming any message.
+	 */
+	isCurrentlyStreaming(): boolean {
+		return this.currentlyStreamingTs !== null
+	}
+
+	/**
+	 * Get the ts of the currently streaming message.
+	 */
+	getCurrentlyStreamingTs(): number | null {
+		return this.currentlyStreamingTs
+	}
+
+	/**
+	 * Mark a message as displayed (useful for external coordination).
+	 */
+	markDisplayed(ts: number, text: string, partial: boolean): void {
+		this.displayedMessages.set(ts, { ts, text, partial })
+	}
+
+	/**
+	 * Clear all tracking state.
+	 * Call this when starting a new task.
+	 */
+	clear(): void {
+		this.displayedMessages.clear()
+		this.streamedContent.clear()
+		this.currentlyStreamingTs = null
+		this.loggedFirstPartial.clear()
+		this.streamingState.next({ ts: null, isStreaming: false })
+	}
+
+	/**
+	 * Get debugging info about first partial logging.
+	 */
+	hasLoggedFirstPartial(ts: number): boolean {
+		return this.loggedFirstPartial.has(ts)
+	}
+
+	/**
+	 * Record that we've logged the first partial for a ts.
+	 */
+	setLoggedFirstPartial(ts: number): void {
+		this.loggedFirstPartial.add(ts)
+	}
+
+	/**
+	 * Clear the first partial record (when complete).
+	 */
+	clearLoggedFirstPartial(ts: number): void {
+		this.loggedFirstPartial.delete(ts)
+	}
+
+	// ===========================================================================
+	// Say Message Output
+	// ===========================================================================
+
+	private outputSayMessage(
+		ts: number,
+		say: ClineSay,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+		skipFirstUserMessage: boolean,
+	): void {
+		switch (say) {
+			case "text":
+				this.outputTextMessage(ts, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
+				break
+
+			// case "thinking": - not a valid ClineSay type
+			case "reasoning":
+				this.outputReasoningMessage(ts, text, isPartial, alreadyDisplayedComplete)
+				break
+
+			case "command_output":
+				this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
+				break
+
+			// Note: completion_result is an "ask" type, not a "say" type.
+			// It is handled via the TaskCompleted event in extension-host.ts
+
+			case "error":
+				if (!alreadyDisplayedComplete) {
+					this.outputError("\n[error]", text || "Unknown error")
+					this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
+				}
+				break
+
+			case "api_req_started":
+				// Silent - no output needed
+				break
+
+			default:
+				// NO-OP for unknown say types
+				break
+		}
+	}
+
+	private outputTextMessage(
+		ts: number,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+		skipFirstUserMessage: boolean,
+	): void {
+		// Skip the initial user prompt echo (first message with no prior messages)
+		if (skipFirstUserMessage && this.displayedMessages.size === 0 && !this.displayedMessages.has(ts)) {
+			this.displayedMessages.set(ts, { ts, text, partial: !!isPartial })
+			return
+		}
+
+		if (isPartial && text) {
+			// Stream partial content
+			this.streamContent(ts, text, "[assistant]")
+			this.displayedMessages.set(ts, { ts, text, partial: true })
+		} else if (!isPartial && text && !alreadyDisplayedComplete) {
+			// Message complete - ensure all content is output
+			const streamed = this.streamedContent.get(ts)
+
+			if (streamed) {
+				// We were streaming - output any remaining delta and finish
+				if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
+					const delta = text.slice(streamed.text.length)
+					this.writeRaw(delta)
+				}
+				this.finishStream(ts)
+			} else {
+				// Not streamed yet - output complete message
+				this.output("\n[assistant]", text)
+			}
+
+			this.displayedMessages.set(ts, { ts, text, partial: false })
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+		}
+	}
+
+	private outputReasoningMessage(
+		ts: number,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+	): void {
+		if (isPartial && text) {
+			this.streamContent(ts, text, "[reasoning]")
+			this.displayedMessages.set(ts, { ts, text, partial: true })
+		} else if (!isPartial && text && !alreadyDisplayedComplete) {
+			// Reasoning complete - finish the stream
+			const streamed = this.streamedContent.get(ts)
+
+			if (streamed) {
+				if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
+					const delta = text.slice(streamed.text.length)
+					this.writeRaw(delta)
+				}
+				this.finishStream(ts)
+			} else {
+				this.output("\n[reasoning]", text)
+			}
+
+			this.displayedMessages.set(ts, { ts, text, partial: false })
+		}
+	}
+
+	/**
+	 * Output command_output (shared between say and ask types).
+	 */
+	outputCommandOutput(
+		ts: number,
+		text: string,
+		isPartial: boolean,
+		alreadyDisplayedComplete: boolean | undefined,
+	): void {
+		if (isPartial && text) {
+			this.streamContent(ts, text, "[command output]")
+			this.displayedMessages.set(ts, { ts, text, partial: true })
+		} else if (!isPartial && text && !alreadyDisplayedComplete) {
+			const streamed = this.streamedContent.get(ts)
+
+			if (streamed) {
+				if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
+					const delta = text.slice(streamed.text.length)
+					this.writeRaw(delta)
+				}
+				this.finishStream(ts)
+			} else {
+				this.writeRaw("\n[command output] ")
+				this.writeRaw(text)
+				this.writeRaw("\n")
+			}
+
+			this.displayedMessages.set(ts, { ts, text, partial: false })
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+		}
+	}
+
+	// ===========================================================================
+	// Streaming Helpers
+	// ===========================================================================
+
+	/**
+	 * Stream content with delta computation - only output new characters.
+	 */
+	streamContent(ts: number, text: string, header: string): void {
+		const previous = this.streamedContent.get(ts)
+
+		if (!previous) {
+			// First time seeing this message - output header and initial text
+			this.writeRaw(`\n${header} `)
+			this.writeRaw(text)
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+			this.currentlyStreamingTs = ts
+			this.streamingState.next({ ts, isStreaming: true })
+		} else if (text.length > previous.text.length && text.startsWith(previous.text)) {
+			// Text has grown - output delta
+			const delta = text.slice(previous.text.length)
+			this.writeRaw(delta)
+			this.streamedContent.set(ts, { ts, text, headerShown: true })
+		}
+	}
+
+	/**
+	 * Finish streaming a message (add newline).
+	 */
+	finishStream(ts: number): void {
+		if (this.currentlyStreamingTs === ts) {
+			this.writeRaw("\n")
+			this.currentlyStreamingTs = null
+			this.streamingState.next({ ts: null, isStreaming: false })
+		}
+	}
+
+	/**
+	 * Output completion message (called from TaskCompleted handler).
+	 */
+	outputCompletionResult(ts: number, text: string): void {
+		const previousDisplay = this.displayedMessages.get(ts)
+		if (!previousDisplay || previousDisplay.partial) {
+			this.output("\n[task complete]", text || "")
+			this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
+		}
+	}
+}

+ 297 - 0
apps/cli/src/agent/prompt-manager.ts

@@ -0,0 +1,297 @@
+/**
+ * PromptManager - Handles all user input collection
+ *
+ * This manager is responsible for:
+ * - Collecting user input via readline
+ * - Yes/No prompts with proper defaults
+ * - Timed prompts that auto-select after timeout
+ * - Raw mode input for character-by-character handling
+ *
+ * Design notes:
+ * - Single responsibility: User input only (no output formatting)
+ * - Returns Promises for all input operations
+ * - Handles console mode switching (quiet mode restore)
+ * - Can be disabled for programmatic (non-interactive) use
+ */
+
+import readline from "readline"
+
+// =============================================================================
+// Types
+// =============================================================================
+
+/**
+ * Configuration options for PromptManager.
+ */
+export interface PromptManagerOptions {
+	/**
+	 * Called before prompting to restore console output.
+	 * Used to exit quiet mode temporarily.
+	 */
+	onBeforePrompt?: () => void
+
+	/**
+	 * Called after prompting to re-enable quiet mode.
+	 */
+	onAfterPrompt?: () => void
+
+	/**
+	 * Stream for input (default: process.stdin).
+	 */
+	stdin?: NodeJS.ReadStream
+
+	/**
+	 * Stream for prompt output (default: process.stdout).
+	 */
+	stdout?: NodeJS.WriteStream
+}
+
+/**
+ * Result of a timed prompt.
+ */
+export interface TimedPromptResult {
+	/** The user's input, or default if timed out */
+	value: string
+	/** Whether the result came from timeout */
+	timedOut: boolean
+	/** Whether the user cancelled (Ctrl+C) */
+	cancelled: boolean
+}
+
+// =============================================================================
+// PromptManager Class
+// =============================================================================
+
+export class PromptManager {
+	private onBeforePrompt?: () => void
+	private onAfterPrompt?: () => void
+	private stdin: NodeJS.ReadStream
+	private stdout: NodeJS.WriteStream
+
+	/**
+	 * Track if a prompt is currently active.
+	 */
+	private isPrompting = false
+
+	constructor(options: PromptManagerOptions = {}) {
+		this.onBeforePrompt = options.onBeforePrompt
+		this.onAfterPrompt = options.onAfterPrompt
+		this.stdin = options.stdin ?? (process.stdin as NodeJS.ReadStream)
+		this.stdout = options.stdout ?? process.stdout
+	}
+
+	// ===========================================================================
+	// Public API
+	// ===========================================================================
+
+	/**
+	 * Check if a prompt is currently active.
+	 */
+	isActive(): boolean {
+		return this.isPrompting
+	}
+
+	/**
+	 * Prompt for text input using readline.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @returns The user's input
+	 * @throws If input is cancelled or an error occurs
+	 */
+	async promptForInput(prompt: string): Promise<string> {
+		return new Promise((resolve, reject) => {
+			this.beforePrompt()
+			this.isPrompting = true
+
+			const rl = readline.createInterface({
+				input: this.stdin,
+				output: this.stdout,
+			})
+
+			rl.question(prompt, (answer) => {
+				rl.close()
+				this.isPrompting = false
+				this.afterPrompt()
+				resolve(answer)
+			})
+
+			rl.on("close", () => {
+				this.isPrompting = false
+				this.afterPrompt()
+			})
+
+			rl.on("error", (err) => {
+				rl.close()
+				this.isPrompting = false
+				this.afterPrompt()
+				reject(err)
+			})
+		})
+	}
+
+	/**
+	 * Prompt for yes/no input.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @param defaultValue - Default value if empty input (default: false)
+	 * @returns true for yes, false for no
+	 */
+	async promptForYesNo(prompt: string, defaultValue = false): Promise<boolean> {
+		const answer = await this.promptForInput(prompt)
+		const normalized = answer.trim().toLowerCase()
+		if (normalized === "" && defaultValue !== undefined) {
+			return defaultValue
+		}
+		return normalized === "y" || normalized === "yes"
+	}
+
+	/**
+	 * Prompt for input with a timeout.
+	 * Uses raw mode for character-by-character input handling.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @param timeoutMs - Timeout in milliseconds
+	 * @param defaultValue - Value to use if timed out
+	 * @returns TimedPromptResult with value, timedOut flag, and cancelled flag
+	 */
+	async promptWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise<TimedPromptResult> {
+		return new Promise((resolve) => {
+			this.beforePrompt()
+			this.isPrompting = true
+
+			// Track the original raw mode state to restore it later
+			const wasRaw = this.stdin.isRaw
+
+			// Enable raw mode for character-by-character input if TTY
+			if (this.stdin.isTTY) {
+				this.stdin.setRawMode(true)
+			}
+
+			this.stdin.resume()
+
+			let inputBuffer = ""
+			let timeoutCancelled = false
+			let resolved = false
+
+			// Set up timeout
+			const timeout = setTimeout(() => {
+				if (!resolved) {
+					resolved = true
+					cleanup()
+					this.stdout.write(`\n[Timeout - using default: ${defaultValue || "(empty)"}]\n`)
+					resolve({ value: defaultValue, timedOut: true, cancelled: false })
+				}
+			}, timeoutMs)
+
+			// Display prompt
+			this.stdout.write(prompt)
+
+			// Cleanup function to restore state
+			const cleanup = () => {
+				clearTimeout(timeout)
+				this.stdin.removeListener("data", onData)
+
+				if (this.stdin.isTTY && wasRaw !== undefined) {
+					this.stdin.setRawMode(wasRaw)
+				}
+
+				this.stdin.pause()
+				this.isPrompting = false
+				this.afterPrompt()
+			}
+
+			// Handle incoming data
+			const onData = (data: Buffer) => {
+				const char = data.toString()
+
+				// Handle Ctrl+C
+				if (char === "\x03") {
+					cleanup()
+					resolved = true
+					this.stdout.write("\n[cancelled]\n")
+					resolve({ value: defaultValue, timedOut: false, cancelled: true })
+					return
+				}
+
+				// Cancel timeout on first input
+				if (!timeoutCancelled) {
+					timeoutCancelled = true
+					clearTimeout(timeout)
+				}
+
+				// Handle Enter
+				if (char === "\r" || char === "\n") {
+					if (!resolved) {
+						resolved = true
+						cleanup()
+						this.stdout.write("\n")
+						resolve({ value: inputBuffer, timedOut: false, cancelled: false })
+					}
+					return
+				}
+
+				// Handle Backspace
+				if (char === "\x7f" || char === "\b") {
+					if (inputBuffer.length > 0) {
+						inputBuffer = inputBuffer.slice(0, -1)
+						this.stdout.write("\b \b")
+					}
+					return
+				}
+
+				// Normal character - add to buffer and echo
+				inputBuffer += char
+				this.stdout.write(char)
+			}
+
+			this.stdin.on("data", onData)
+		})
+	}
+
+	/**
+	 * Prompt for yes/no with timeout.
+	 *
+	 * @param prompt - The prompt text to display
+	 * @param timeoutMs - Timeout in milliseconds
+	 * @param defaultValue - Default boolean value if timed out
+	 * @returns true for yes, false for no
+	 */
+	async promptForYesNoWithTimeout(prompt: string, timeoutMs: number, defaultValue: boolean): Promise<boolean> {
+		const result = await this.promptWithTimeout(prompt, timeoutMs, defaultValue ? "y" : "n")
+		const normalized = result.value.trim().toLowerCase()
+		if (result.timedOut || result.cancelled || normalized === "") {
+			return defaultValue
+		}
+		return normalized === "y" || normalized === "yes"
+	}
+
+	/**
+	 * Display a message on stdout (utility for prompting context).
+	 */
+	write(text: string): void {
+		this.stdout.write(text)
+	}
+
+	/**
+	 * Display a message with newline.
+	 */
+	writeLine(text: string): void {
+		this.stdout.write(text + "\n")
+	}
+
+	// ===========================================================================
+	// Private Helpers
+	// ===========================================================================
+
+	private beforePrompt(): void {
+		if (this.onBeforePrompt) {
+			this.onBeforePrompt()
+		}
+	}
+
+	private afterPrompt(): void {
+		if (this.onAfterPrompt) {
+			this.onAfterPrompt()
+		}
+	}
+}

+ 415 - 0
apps/cli/src/agent/state-store.ts

@@ -0,0 +1,415 @@
+/**
+ * State Store
+ *
+ * This module manages the client's internal state, including:
+ * - The clineMessages array (source of truth for agent state)
+ * - The computed agent state info
+ * - Any extension state we want to cache
+ *
+ * The store is designed to be:
+ * - Immutable: State updates create new objects, not mutations
+ * - Observable: Changes trigger notifications
+ * - Queryable: Current state is always accessible
+ */
+
+import { ClineMessage, ExtensionState } from "@roo-code/types"
+
+import { detectAgentState, AgentStateInfo, AgentLoopState } from "./agent-state.js"
+import { Observable } from "./events.js"
+
+// =============================================================================
+// Store State Interface
+// =============================================================================
+
+/**
+ * The complete state managed by the store.
+ */
+export interface StoreState {
+	/**
+	 * The array of messages from the extension.
+	 * This is the primary data used to compute agent state.
+	 */
+	messages: ClineMessage[]
+
+	/**
+	 * The computed agent state info.
+	 * Updated automatically when messages change.
+	 */
+	agentState: AgentStateInfo
+
+	/**
+	 * Whether we have received any state from the extension.
+	 * Useful to distinguish "no task" from "not yet connected".
+	 */
+	isInitialized: boolean
+
+	/**
+	 * The last time state was updated.
+	 */
+	lastUpdatedAt: number
+
+	/**
+	 * The current mode (e.g., "code", "architect", "ask").
+	 * Tracked from state messages received from the extension.
+	 */
+	currentMode: string | undefined
+
+	/**
+	 * Optional: Cache of extension state fields we might need.
+	 * This is a subset of the full ExtensionState.
+	 */
+	extensionState?: Partial<ExtensionState>
+}
+
+/**
+ * Create the initial store state.
+ */
+function createInitialState(): StoreState {
+	return {
+		messages: [],
+		agentState: detectAgentState([]),
+		isInitialized: false,
+		lastUpdatedAt: Date.now(),
+		currentMode: undefined,
+	}
+}
+
+// =============================================================================
+// State Store Class
+// =============================================================================
+
+/**
+ * StateStore manages all client state and provides reactive updates.
+ *
+ * Key features:
+ * - Stores the clineMessages array
+ * - Automatically computes agent state when messages change
+ * - Provides observable pattern for state changes
+ * - Tracks state history for debugging (optional)
+ *
+ * Usage:
+ * ```typescript
+ * const store = new StateStore()
+ *
+ * // Subscribe to state changes
+ * store.subscribe((state) => {
+ *   console.log('New state:', state.agentState.state)
+ * })
+ *
+ * // Update messages
+ * store.setMessages(newMessages)
+ *
+ * // Query current state
+ * const currentState = store.getState()
+ * ```
+ */
+export class StateStore {
+	private state: StoreState
+	private stateObservable: Observable<StoreState>
+	private agentStateObservable: Observable<AgentStateInfo>
+
+	/**
+	 * Optional: Track state history for debugging.
+	 * Set maxHistorySize to enable.
+	 */
+	private stateHistory: StoreState[] = []
+	private maxHistorySize: number
+
+	constructor(options: { maxHistorySize?: number } = {}) {
+		this.state = createInitialState()
+		this.stateObservable = new Observable<StoreState>(this.state)
+		this.agentStateObservable = new Observable<AgentStateInfo>(this.state.agentState)
+		this.maxHistorySize = options.maxHistorySize ?? 0
+	}
+
+	// ===========================================================================
+	// State Queries
+	// ===========================================================================
+
+	/**
+	 * Get the current complete state.
+	 */
+	getState(): StoreState {
+		return this.state
+	}
+
+	/**
+	 * Get just the agent state info.
+	 * This is a convenience method for the most common query.
+	 */
+	getAgentState(): AgentStateInfo {
+		return this.state.agentState
+	}
+
+	/**
+	 * Get the current messages array.
+	 */
+	getMessages(): ClineMessage[] {
+		return this.state.messages
+	}
+
+	/**
+	 * Get the last message, if any.
+	 */
+	getLastMessage(): ClineMessage | undefined {
+		return this.state.messages[this.state.messages.length - 1]
+	}
+
+	/**
+	 * Check if the store has been initialized with extension state.
+	 */
+	isInitialized(): boolean {
+		return this.state.isInitialized
+	}
+
+	/**
+	 * Quick check: Is the agent currently waiting for input?
+	 */
+	isWaitingForInput(): boolean {
+		return this.state.agentState.isWaitingForInput
+	}
+
+	/**
+	 * Quick check: Is the agent currently running?
+	 */
+	isRunning(): boolean {
+		return this.state.agentState.isRunning
+	}
+
+	/**
+	 * Quick check: Is content currently streaming?
+	 */
+	isStreaming(): boolean {
+		return this.state.agentState.isStreaming
+	}
+
+	/**
+	 * Get the current agent loop state enum value.
+	 */
+	getCurrentState(): AgentLoopState {
+		return this.state.agentState.state
+	}
+
+	/**
+	 * Get the current mode (e.g., "code", "architect", "ask").
+	 */
+	getCurrentMode(): string | undefined {
+		return this.state.currentMode
+	}
+
+	// ===========================================================================
+	// State Updates
+	// ===========================================================================
+
+	/**
+	 * Set the complete messages array.
+	 * This is typically called when receiving a full state update from the extension.
+	 *
+	 * @param messages - The new messages array
+	 * @returns The previous agent state (for comparison)
+	 */
+	setMessages(messages: ClineMessage[]): AgentStateInfo {
+		const previousAgentState = this.state.agentState
+		const newAgentState = detectAgentState(messages)
+
+		this.updateState({
+			messages,
+			agentState: newAgentState,
+			isInitialized: true,
+			lastUpdatedAt: Date.now(),
+			currentMode: this.state.currentMode, // Preserve mode across message updates
+		})
+
+		return previousAgentState
+	}
+
+	/**
+	 * Add a single message to the end of the messages array.
+	 * Useful when receiving incremental updates.
+	 *
+	 * @param message - The message to add
+	 * @returns The previous agent state
+	 */
+	addMessage(message: ClineMessage): AgentStateInfo {
+		const newMessages = [...this.state.messages, message]
+		return this.setMessages(newMessages)
+	}
+
+	/**
+	 * Update a message in place (e.g., when partial becomes complete).
+	 * Finds the message by timestamp and replaces it.
+	 *
+	 * @param message - The updated message
+	 * @returns The previous agent state, or undefined if message not found
+	 */
+	updateMessage(message: ClineMessage): AgentStateInfo | undefined {
+		const index = this.state.messages.findIndex((m) => m.ts === message.ts)
+		if (index === -1) {
+			// Message not found, add it instead
+			return this.addMessage(message)
+		}
+
+		const newMessages = [...this.state.messages]
+		newMessages[index] = message
+		return this.setMessages(newMessages)
+	}
+
+	/**
+	 * Clear all messages and reset to initial state.
+	 * Called when a task is cleared/cancelled.
+	 */
+	clear(): void {
+		this.updateState({
+			messages: [],
+			agentState: detectAgentState([]),
+			isInitialized: true, // Still initialized, just empty
+			lastUpdatedAt: Date.now(),
+			currentMode: this.state.currentMode, // Preserve mode when clearing task
+			extensionState: undefined,
+		})
+	}
+
+	/**
+	 * Set the current mode.
+	 * Called when mode changes are detected from extension state messages.
+	 *
+	 * @param mode - The new mode value
+	 */
+	setCurrentMode(mode: string | undefined): void {
+		if (this.state.currentMode !== mode) {
+			this.updateState({
+				...this.state,
+				currentMode: mode,
+				lastUpdatedAt: Date.now(),
+			})
+		}
+	}
+
+	/**
+	 * Reset to completely uninitialized state.
+	 * Called on disconnect or reset.
+	 */
+	reset(): void {
+		this.state = createInitialState()
+		this.stateHistory = []
+		// Don't notify on reset - we're starting fresh
+	}
+
+	/**
+	 * Update cached extension state.
+	 * This stores any additional extension state fields we might need.
+	 *
+	 * @param extensionState - The extension state to cache
+	 */
+	setExtensionState(extensionState: Partial<ExtensionState>): void {
+		// Extract and store messages if present
+		if (extensionState.clineMessages) {
+			this.setMessages(extensionState.clineMessages)
+		}
+
+		// Store the rest of the extension state
+		this.updateState({
+			...this.state,
+			extensionState: {
+				...this.state.extensionState,
+				...extensionState,
+			},
+		})
+	}
+
+	// ===========================================================================
+	// Subscriptions
+	// ===========================================================================
+
+	/**
+	 * Subscribe to all state changes.
+	 *
+	 * @param observer - Callback function receiving the new state
+	 * @returns Unsubscribe function
+	 */
+	subscribe(observer: (state: StoreState) => void): () => void {
+		return this.stateObservable.subscribe(observer)
+	}
+
+	/**
+	 * Subscribe to agent state changes only.
+	 * This is more efficient if you only care about agent state.
+	 *
+	 * @param observer - Callback function receiving the new agent state
+	 * @returns Unsubscribe function
+	 */
+	subscribeToAgentState(observer: (state: AgentStateInfo) => void): () => void {
+		return this.agentStateObservable.subscribe(observer)
+	}
+
+	// ===========================================================================
+	// History (for debugging)
+	// ===========================================================================
+
+	/**
+	 * Get the state history (if enabled).
+	 */
+	getHistory(): StoreState[] {
+		return [...this.stateHistory]
+	}
+
+	/**
+	 * Clear the state history.
+	 */
+	clearHistory(): void {
+		this.stateHistory = []
+	}
+
+	// ===========================================================================
+	// Private Methods
+	// ===========================================================================
+
+	/**
+	 * Internal method to update state and notify observers.
+	 */
+	private updateState(newState: StoreState): void {
+		// Track history if enabled
+		if (this.maxHistorySize > 0) {
+			this.stateHistory.push(this.state)
+			if (this.stateHistory.length > this.maxHistorySize) {
+				this.stateHistory.shift()
+			}
+		}
+
+		this.state = newState
+
+		// Notify observers
+		this.stateObservable.next(this.state)
+		this.agentStateObservable.next(this.state.agentState)
+	}
+}
+
+// =============================================================================
+// Singleton Store (optional convenience)
+// =============================================================================
+
+let defaultStore: StateStore | null = null
+
+/**
+ * Get the default singleton store instance.
+ * Useful for simple applications that don't need multiple stores.
+ */
+export function getDefaultStore(): StateStore {
+	if (!defaultStore) {
+		defaultStore = new StateStore()
+	}
+
+	return defaultStore
+}
+
+/**
+ * Reset the default store instance.
+ * Useful for testing or when you need a fresh start.
+ */
+export function resetDefaultStore(): void {
+	if (defaultStore) {
+		defaultStore.reset()
+	}
+
+	defaultStore = null
+}

+ 3 - 0
apps/cli/src/commands/auth/index.ts

@@ -0,0 +1,3 @@
+export * from "./login.js"
+export * from "./logout.js"
+export * from "./status.js"

+ 186 - 0
apps/cli/src/commands/auth/login.ts

@@ -0,0 +1,186 @@
+import http from "http"
+import { randomBytes } from "crypto"
+import net from "net"
+import { exec } from "child_process"
+
+import { AUTH_BASE_URL } from "@/types/index.js"
+import { saveToken } from "@/lib/storage/index.js"
+
+export interface LoginOptions {
+	timeout?: number
+	verbose?: boolean
+}
+
+export interface LoginResult {
+	success: boolean
+	error?: string
+	userId?: string
+	orgId?: string | null
+}
+
+const LOCALHOST = "127.0.0.1"
+
+export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise<LoginResult> {
+	const state = randomBytes(16).toString("hex")
+	const port = await getAvailablePort()
+	const host = `http://${LOCALHOST}:${port}`
+
+	if (verbose) {
+		console.log(`[Auth] Starting local callback server on port ${port}`)
+	}
+
+	// Create promise that will be resolved when we receive the callback.
+	const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => {
+		const server = http.createServer((req, res) => {
+			const url = new URL(req.url!, host)
+
+			if (url.pathname === "/callback") {
+				const receivedState = url.searchParams.get("state")
+				const token = url.searchParams.get("token")
+				const error = url.searchParams.get("error")
+
+				if (error) {
+					const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`)
+					errorUrl.searchParams.set("message", error)
+					res.writeHead(302, { Location: errorUrl.toString() })
+					res.end()
+					// Wait for response to be fully sent before closing server and rejecting.
+					// The 'close' event fires when the underlying connection is terminated,
+					// ensuring the browser has received the redirect before we shut down.
+					res.on("close", () => {
+						server.close()
+						reject(new Error(error))
+					})
+				} else if (!token) {
+					const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`)
+					errorUrl.searchParams.set("message", "Missing token in callback")
+					res.writeHead(302, { Location: errorUrl.toString() })
+					res.end()
+					res.on("close", () => {
+						server.close()
+						reject(new Error("Missing token in callback"))
+					})
+				} else if (receivedState !== state) {
+					const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`)
+					errorUrl.searchParams.set("message", "Invalid state parameter (possible CSRF attack)")
+					res.writeHead(302, { Location: errorUrl.toString() })
+					res.end()
+					res.on("close", () => {
+						server.close()
+						reject(new Error("Invalid state parameter"))
+					})
+				} else {
+					res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` })
+					res.end()
+					res.on("close", () => {
+						server.close()
+						resolve({ token, state: receivedState })
+					})
+				}
+			} else {
+				res.writeHead(404, { "Content-Type": "text/plain" })
+				res.end("Not found")
+			}
+		})
+
+		server.listen(port, LOCALHOST)
+
+		const timeoutId = setTimeout(() => {
+			server.close()
+			reject(new Error("Authentication timed out"))
+		}, timeout)
+
+		server.on("listening", () => {
+			console.log(`[Auth] Callback server listening on port ${port}`)
+		})
+
+		server.on("close", () => {
+			console.log("[Auth] Callback server closed")
+			clearTimeout(timeoutId)
+		})
+	})
+
+	const authUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in`)
+	authUrl.searchParams.set("state", state)
+	authUrl.searchParams.set("callback", `${host}/callback`)
+
+	console.log("Opening browser for authentication...")
+	console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`)
+
+	try {
+		await openBrowser(authUrl.toString())
+	} catch (error) {
+		if (verbose) {
+			console.warn("[Auth] Failed to open browser automatically:", error)
+		}
+
+		console.log("Please open the URL above in your browser manually.")
+	}
+
+	try {
+		const { token } = await tokenPromise
+		await saveToken(token)
+		console.log("✓ Successfully authenticated!")
+		return { success: true }
+	} catch (error) {
+		const message = error instanceof Error ? error.message : String(error)
+		console.error(`✗ Authentication failed: ${message}`)
+		return { success: false, error: message }
+	}
+}
+
+async function getAvailablePort(startPort = 49152, endPort = 65535): Promise<number> {
+	return new Promise((resolve, reject) => {
+		const server = net.createServer()
+		let port = startPort
+
+		const tryPort = () => {
+			server.once("error", (err: NodeJS.ErrnoException) => {
+				if (err.code === "EADDRINUSE" && port < endPort) {
+					port++
+					tryPort()
+				} else {
+					reject(err)
+				}
+			})
+
+			server.once("listening", () => {
+				server.close(() => {
+					resolve(port)
+				})
+			})
+
+			server.listen(port, LOCALHOST)
+		}
+
+		tryPort()
+	})
+}
+
+function openBrowser(url: string): Promise<void> {
+	return new Promise((resolve, reject) => {
+		const platform = process.platform
+		let command: string
+
+		switch (platform) {
+			case "darwin":
+				command = `open "${url}"`
+				break
+			case "win32":
+				command = `start "" "${url}"`
+				break
+			default:
+				// Linux and other Unix-like systems.
+				command = `xdg-open "${url}"`
+				break
+		}
+
+		exec(command, (error) => {
+			if (error) {
+				reject(error)
+			} else {
+				resolve()
+			}
+		})
+	})
+}

+ 27 - 0
apps/cli/src/commands/auth/logout.ts

@@ -0,0 +1,27 @@
+import { clearToken, hasToken, getCredentialsPath } from "@/lib/storage/index.js"
+
+export interface LogoutOptions {
+	verbose?: boolean
+}
+
+export interface LogoutResult {
+	success: boolean
+	wasLoggedIn: boolean
+}
+
+export async function logout({ verbose = false }: LogoutOptions = {}): Promise<LogoutResult> {
+	const wasLoggedIn = await hasToken()
+
+	if (!wasLoggedIn) {
+		console.log("You are not currently logged in.")
+		return { success: true, wasLoggedIn: false }
+	}
+
+	if (verbose) {
+		console.log(`[Auth] Removing credentials from ${getCredentialsPath()}`)
+	}
+
+	await clearToken()
+	console.log("✓ Successfully logged out")
+	return { success: true, wasLoggedIn: true }
+}

+ 97 - 0
apps/cli/src/commands/auth/status.ts

@@ -0,0 +1,97 @@
+import { loadToken, loadCredentials, getCredentialsPath } from "@/lib/storage/index.js"
+import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "@/lib/auth/index.js"
+
+export interface StatusOptions {
+	verbose?: boolean
+}
+
+export interface StatusResult {
+	authenticated: boolean
+	expired?: boolean
+	expiringSoon?: boolean
+	userId?: string
+	orgId?: string | null
+	expiresAt?: Date
+	createdAt?: Date
+}
+
+export async function status(options: StatusOptions = {}): Promise<StatusResult> {
+	const { verbose = false } = options
+
+	const token = await loadToken()
+
+	if (!token) {
+		console.log("✗ Not authenticated")
+		console.log("")
+		console.log("Run: roo auth login")
+		return { authenticated: false }
+	}
+
+	const expiresAt = getTokenExpirationDate(token)
+	const expired = !isTokenValid(token)
+	const expiringSoon = isTokenExpired(token, 24 * 60 * 60) && !expired
+
+	const credentials = await loadCredentials()
+	const createdAt = credentials?.createdAt ? new Date(credentials.createdAt) : undefined
+
+	if (expired) {
+		console.log("✗ Authentication token expired")
+		console.log("")
+		console.log("Run: roo auth login")
+
+		return {
+			authenticated: false,
+			expired: true,
+			expiresAt: expiresAt ?? undefined,
+		}
+	}
+
+	if (expiringSoon) {
+		console.log("⚠ Expires soon; refresh with `roo auth login`")
+	} else {
+		console.log("✓ Authenticated")
+	}
+
+	if (expiresAt) {
+		const remaining = getTimeRemaining(expiresAt)
+		console.log(`  Expires:      ${formatDate(expiresAt)} (${remaining})`)
+	}
+
+	if (createdAt && verbose) {
+		console.log(`  Created:      ${formatDate(createdAt)}`)
+	}
+
+	if (verbose) {
+		console.log(`  Credentials:  ${getCredentialsPath()}`)
+	}
+
+	return {
+		authenticated: true,
+		expired: false,
+		expiringSoon,
+		expiresAt: expiresAt ?? undefined,
+		createdAt,
+	}
+}
+
+function formatDate(date: Date): string {
+	return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
+}
+
+function getTimeRemaining(date: Date): string {
+	const now = new Date()
+	const diff = date.getTime() - now.getTime()
+
+	if (diff <= 0) {
+		return "expired"
+	}
+
+	const days = Math.floor(diff / (1000 * 60 * 60 * 24))
+	const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
+
+	if (days > 0) {
+		return `${days} day${days === 1 ? "" : "s"}`
+	}
+
+	return `${hours} hour${hours === 1 ? "" : "s"}`
+}

+ 1 - 0
apps/cli/src/commands/cli/index.ts

@@ -0,0 +1 @@
+export * from "./run.js"

+ 219 - 0
apps/cli/src/commands/cli/run.ts

@@ -0,0 +1,219 @@
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from "url"
+
+import { createElement } from "react"
+
+import { isProviderName } from "@roo-code/types"
+import { setLogger } from "@roo-code/vscode-shim"
+
+import {
+	FlagOptions,
+	isSupportedProvider,
+	OnboardingProviderChoice,
+	supportedProviders,
+	ASCII_ROO,
+	DEFAULT_FLAGS,
+	REASONING_EFFORTS,
+	SDK_BASE_URL,
+} from "@/types/index.js"
+
+import { type User, createClient } from "@/lib/sdk/index.js"
+import { loadToken, hasToken, loadSettings } from "@/lib/storage/index.js"
+import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
+import { runOnboarding } from "@/lib/utils/onboarding.js"
+import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
+import { VERSION } from "@/lib/utils/version.js"
+
+import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+
+export async function run(workspaceArg: string, options: FlagOptions) {
+	setLogger({
+		info: () => {},
+		warn: () => {},
+		error: () => {},
+		debug: () => {},
+	})
+
+	const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
+	const isTuiEnabled = options.tui && isTuiSupported
+	const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
+	const workspacePath = path.resolve(workspaceArg)
+
+	if (!isSupportedProvider(options.provider)) {
+		console.error(
+			`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
+		)
+
+		process.exit(1)
+	}
+
+	let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
+	let provider = options.provider
+	let user: User | null = null
+	let useCloudProvider = false
+
+	if (isTuiEnabled) {
+		let { onboardingProviderChoice } = await loadSettings()
+
+		if (!onboardingProviderChoice) {
+			const result = await runOnboarding()
+			onboardingProviderChoice = result.choice
+		}
+
+		if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
+			useCloudProvider = true
+			const authenticated = await hasToken()
+
+			if (authenticated) {
+				const token = await loadToken()
+
+				if (token) {
+					try {
+						const client = createClient({ url: SDK_BASE_URL, authToken: token })
+						const me = await client.auth.me.query()
+						provider = "roo"
+						apiKey = token
+						user = me?.type === "user" ? me.user : null
+					} catch {
+						// Token may be expired or invalid - user will need to re-authenticate.
+					}
+				}
+			}
+		}
+	}
+
+	if (!apiKey) {
+		if (useCloudProvider) {
+			console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
+			console.error("[CLI] Please run: roo auth login")
+			console.error("[CLI] Or use --api-key to provide your own API key.")
+		} else {
+			console.error(
+				`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
+			)
+			console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
+		}
+
+		process.exit(1)
+	}
+
+	if (!fs.existsSync(workspacePath)) {
+		console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
+		process.exit(1)
+	}
+
+	if (!isProviderName(options.provider)) {
+		console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
+		process.exit(1)
+	}
+
+	if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
+		console.error(
+			`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
+		)
+		process.exit(1)
+	}
+
+	if (options.tui && !isTuiSupported) {
+		console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
+	}
+
+	if (!isTuiEnabled && !options.prompt) {
+		console.error("[CLI] Error: prompt is required in plain text mode")
+		console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
+		console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
+		process.exit(1)
+	}
+
+	if (isTuiEnabled) {
+		try {
+			const { render } = await import("ink")
+			const { App } = await import("../../ui/App.js")
+
+			render(
+				createElement(App, {
+					initialPrompt: options.prompt || "",
+					workspacePath: workspacePath,
+					extensionPath: path.resolve(extensionPath),
+					user,
+					provider,
+					apiKey,
+					model: options.model || DEFAULT_FLAGS.model,
+					mode: options.mode || DEFAULT_FLAGS.mode,
+					nonInteractive: options.yes,
+					debug: options.debug,
+					exitOnComplete: options.exitOnComplete,
+					reasoningEffort: options.reasoningEffort,
+					ephemeral: options.ephemeral,
+					version: VERSION,
+					// Create extension host factory for dependency injection.
+					createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
+				}),
+				// Handle Ctrl+C in App component for double-press exit.
+				{ exitOnCtrlC: false },
+			)
+		} catch (error) {
+			console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
+
+			if (error instanceof Error) {
+				console.error(error.stack)
+			}
+
+			process.exit(1)
+		}
+	} else {
+		console.log(ASCII_ROO)
+		console.log()
+		console.log(
+			`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
+		)
+
+		const host = new ExtensionHost({
+			mode: options.mode || DEFAULT_FLAGS.mode,
+			reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
+			user,
+			provider,
+			apiKey,
+			model: options.model || DEFAULT_FLAGS.model,
+			workspacePath,
+			extensionPath: path.resolve(extensionPath),
+			nonInteractive: options.yes,
+			ephemeral: options.ephemeral,
+			debug: options.debug,
+		})
+
+		process.on("SIGINT", async () => {
+			console.log("\n[CLI] Received SIGINT, shutting down...")
+			await host.dispose()
+			process.exit(130)
+		})
+
+		process.on("SIGTERM", async () => {
+			console.log("\n[CLI] Received SIGTERM, shutting down...")
+			await host.dispose()
+			process.exit(143)
+		})
+
+		try {
+			await host.activate()
+			await host.runTask(options.prompt!)
+			await host.dispose()
+
+			if (!options.waitOnComplete) {
+				process.exit(0)
+			}
+		} catch (error) {
+			console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
+
+			if (error instanceof Error) {
+				console.error(error.stack)
+			}
+
+			await host.dispose()
+			process.exit(1)
+		}
+	}
+}

+ 2 - 0
apps/cli/src/commands/index.ts

@@ -0,0 +1,2 @@
+export * from "./auth/index.js"
+export * from "./cli/index.js"

+ 65 - 0
apps/cli/src/index.ts

@@ -0,0 +1,65 @@
+import { Command } from "commander"
+
+import { DEFAULT_FLAGS } from "@/types/constants.js"
+import { VERSION } from "@/lib/utils/version.js"
+import { run, login, logout, status } from "@/commands/index.js"
+
+const program = new Command()
+
+program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version(VERSION)
+
+program
+	.argument("[workspace]", "Workspace path to operate in", process.cwd())
+	.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
+	.option("-e, --extension <path>", "Path to the extension bundle directory")
+	.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
+	.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
+	.option("-k, --api-key <key>", "API key for the LLM provider (defaults to OPENROUTER_API_KEY env var)")
+	.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
+	.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
+	.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
+	.option(
+		"-r, --reasoning-effort <effort>",
+		"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
+		DEFAULT_FLAGS.reasoningEffort,
+	)
+	.option("-x, --exit-on-complete", "Exit the process when the task completes (applies to TUI mode only)", false)
+	.option(
+		"-w, --wait-on-complete",
+		"Keep the process running when the task completes (applies to plain text mode only)",
+		false,
+	)
+	.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
+	.option("--no-tui", "Disable TUI, use plain text output")
+	.action(run)
+
+const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
+
+authCommand
+	.command("login")
+	.description("Authenticate with Roo Code Cloud")
+	.option("-v, --verbose", "Enable verbose output", false)
+	.action(async (options: { verbose: boolean }) => {
+		const result = await login({ verbose: options.verbose })
+		process.exit(result.success ? 0 : 1)
+	})
+
+authCommand
+	.command("logout")
+	.description("Log out from Roo Code Cloud")
+	.option("-v, --verbose", "Enable verbose output", false)
+	.action(async (options: { verbose: boolean }) => {
+		const result = await logout({ verbose: options.verbose })
+		process.exit(result.success ? 0 : 1)
+	})
+
+authCommand
+	.command("status")
+	.description("Show authentication status")
+	.option("-v, --verbose", "Enable verbose output", false)
+	.action(async (options: { verbose: boolean }) => {
+		const result = await status({ verbose: options.verbose })
+		process.exit(result.authenticated ? 0 : 1)
+	})
+
+program.parse()

+ 1 - 0
apps/cli/src/lib/auth/index.ts

@@ -0,0 +1 @@
+export * from "./token.js"

+ 61 - 0
apps/cli/src/lib/auth/token.ts

@@ -0,0 +1,61 @@
+export interface DecodedToken {
+	iss: string
+	sub: string
+	exp: number
+	iat: number
+	nbf: number
+	v: number
+	r?: {
+		u?: string
+		o?: string
+		t: string
+	}
+}
+
+function decodeToken(token: string): DecodedToken | null {
+	try {
+		const parts = token.split(".")
+
+		if (parts.length !== 3) {
+			return null
+		}
+
+		const payload = parts[1]
+
+		if (!payload) {
+			return null
+		}
+
+		const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
+		const decoded = Buffer.from(padded, "base64url").toString("utf-8")
+		return JSON.parse(decoded) as DecodedToken
+	} catch {
+		return null
+	}
+}
+
+export function isTokenExpired(token: string, bufferSeconds = 24 * 60 * 60): boolean {
+	const decoded = decodeToken(token)
+
+	if (!decoded?.exp) {
+		return true
+	}
+
+	const expiresAt = decoded.exp
+	const bufferTime = Math.floor(Date.now() / 1000) + bufferSeconds
+	return expiresAt < bufferTime
+}
+
+export function isTokenValid(token: string): boolean {
+	return !isTokenExpired(token, 0)
+}
+
+export function getTokenExpirationDate(token: string): Date | null {
+	const decoded = decodeToken(token)
+
+	if (!decoded?.exp) {
+		return null
+	}
+
+	return new Date(decoded.exp * 1000)
+}

+ 30 - 0
apps/cli/src/lib/sdk/client.ts

@@ -0,0 +1,30 @@
+import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"
+import superjson from "superjson"
+
+import type { User, Org } from "./types.js"
+
+export interface ClientConfig {
+	url: string
+	authToken: string
+}
+
+export interface RooClient {
+	auth: {
+		me: {
+			query: () => Promise<{ type: "user"; user: User } | { type: "org"; org: Org } | null>
+		}
+	}
+}
+
+export const createClient = ({ url, authToken }: ClientConfig): RooClient => {
+	// eslint-disable-next-line @typescript-eslint/no-explicit-any
+	return createTRPCProxyClient<any>({
+		links: [
+			httpBatchLink({
+				url: `${url}/trpc`,
+				transformer: superjson,
+				headers: () => (authToken ? { Authorization: `Bearer ${authToken}` } : {}),
+			}),
+		],
+	}) as unknown as RooClient
+}

+ 2 - 0
apps/cli/src/lib/sdk/index.ts

@@ -0,0 +1,2 @@
+export * from "./types.js"
+export * from "./client.js"

+ 31 - 0
apps/cli/src/lib/sdk/types.ts

@@ -0,0 +1,31 @@
+export interface User {
+	id: string
+	name: string
+	email: string
+	imageUrl: string | null
+	entity: {
+		id: string
+		username: string | null
+		image_url: string
+		last_name: string
+		first_name: string
+		email_addresses: { email_address: string }[]
+		public_metadata: Record<string, unknown>
+	}
+	publicMetadata: Record<string, unknown>
+	stripeCustomerId: string | null
+	lastSyncAt: string
+	deletedAt: string | null
+	createdAt: string
+	updatedAt: string
+}
+
+export interface Org {
+	id: string
+	name: string
+	slug: string
+	imageUrl: string | null
+	createdAt: string
+	updatedAt: string
+	deletedAt: string | null
+}

+ 152 - 0
apps/cli/src/lib/storage/__tests__/credentials.test.ts

@@ -0,0 +1,152 @@
+import fs from "fs/promises"
+import path from "path"
+
+// Use vi.hoisted to make the test directory available to the mock
+// This must return the path synchronously since CREDENTIALS_FILE is computed at import time
+const { getTestConfigDir } = vi.hoisted(() => {
+	// eslint-disable-next-line @typescript-eslint/no-require-imports
+	const os = require("os")
+	// eslint-disable-next-line @typescript-eslint/no-require-imports
+	const path = require("path")
+	const testRunId = Date.now().toString()
+	const testConfigDir = path.join(os.tmpdir(), `roo-cli-test-${testRunId}`)
+	return { getTestConfigDir: () => testConfigDir }
+})
+
+vi.mock("../config-dir.js", () => ({
+	getConfigDir: getTestConfigDir,
+}))
+
+// Import after mocking
+import { saveToken, loadToken, loadCredentials, clearToken, hasToken, getCredentialsPath } from "../credentials.js"
+
+// Re-derive the test config dir for use in tests (must match the hoisted one)
+const actualTestConfigDir = getTestConfigDir()
+
+describe("Token Storage", () => {
+	const expectedCredentialsFile = path.join(actualTestConfigDir, "cli-credentials.json")
+
+	beforeEach(async () => {
+		// Clear test directory before each test
+		await fs.rm(actualTestConfigDir, { recursive: true, force: true })
+	})
+
+	afterAll(async () => {
+		// Clean up test directory
+		await fs.rm(actualTestConfigDir, { recursive: true, force: true })
+	})
+
+	describe("getCredentialsPath", () => {
+		it("should return the correct credentials file path", () => {
+			expect(getCredentialsPath()).toBe(expectedCredentialsFile)
+		})
+	})
+
+	describe("saveToken", () => {
+		it("should save token to disk", async () => {
+			const token = "test-token-123"
+			await saveToken(token)
+
+			const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
+			const credentials = JSON.parse(savedData)
+
+			expect(credentials.token).toBe(token)
+			expect(credentials.createdAt).toBeDefined()
+		})
+
+		it("should save token with user info", async () => {
+			const token = "test-token-456"
+			await saveToken(token, { userId: "user_123", orgId: "org_456" })
+
+			const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
+			const credentials = JSON.parse(savedData)
+
+			expect(credentials.token).toBe(token)
+			expect(credentials.userId).toBe("user_123")
+			expect(credentials.orgId).toBe("org_456")
+		})
+
+		it("should create config directory if it doesn't exist", async () => {
+			const token = "test-token-789"
+			await saveToken(token)
+
+			const dirStats = await fs.stat(actualTestConfigDir)
+			expect(dirStats.isDirectory()).toBe(true)
+		})
+
+		// Unix file permissions don't apply on Windows - skip this test
+		it.skipIf(process.platform === "win32")("should set restrictive file permissions", async () => {
+			const token = "test-token-perms"
+			await saveToken(token)
+
+			const stats = await fs.stat(expectedCredentialsFile)
+			// Check that only owner has read/write (mode 0o600)
+			const mode = stats.mode & 0o777
+			expect(mode).toBe(0o600)
+		})
+	})
+
+	describe("loadToken", () => {
+		it("should load saved token", async () => {
+			const token = "test-token-abc"
+			await saveToken(token)
+
+			const loaded = await loadToken()
+			expect(loaded).toBe(token)
+		})
+
+		it("should return null if no token exists", async () => {
+			const loaded = await loadToken()
+			expect(loaded).toBeNull()
+		})
+	})
+
+	describe("loadCredentials", () => {
+		it("should load full credentials", async () => {
+			const token = "test-token-def"
+			await saveToken(token, { userId: "user_789" })
+
+			const credentials = await loadCredentials()
+
+			expect(credentials).not.toBeNull()
+			expect(credentials?.token).toBe(token)
+			expect(credentials?.userId).toBe("user_789")
+			expect(credentials?.createdAt).toBeDefined()
+		})
+
+		it("should return null if no credentials exist", async () => {
+			const credentials = await loadCredentials()
+			expect(credentials).toBeNull()
+		})
+	})
+
+	describe("clearToken", () => {
+		it("should remove saved token", async () => {
+			const token = "test-token-ghi"
+			await saveToken(token)
+
+			await clearToken()
+
+			const loaded = await loadToken()
+			expect(loaded).toBeNull()
+		})
+
+		it("should not throw if no token exists", async () => {
+			await expect(clearToken()).resolves.not.toThrow()
+		})
+	})
+
+	describe("hasToken", () => {
+		it("should return true if token exists", async () => {
+			await saveToken("test-token-jkl")
+
+			const exists = await hasToken()
+			expect(exists).toBe(true)
+		})
+
+		it("should return false if no token exists", async () => {
+			const exists = await hasToken()
+			expect(exists).toBe(false)
+		})
+	})
+})

+ 240 - 0
apps/cli/src/lib/storage/__tests__/history.test.ts

@@ -0,0 +1,240 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+
+import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../history.js"
+
+vi.mock("fs/promises")
+
+vi.mock("os", async (importOriginal) => {
+	const actual = await importOriginal<typeof import("os")>()
+	return {
+		...actual,
+		default: {
+			...actual,
+			homedir: vi.fn(() => "/home/testuser"),
+		},
+		homedir: vi.fn(() => "/home/testuser"),
+	}
+})
+
+describe("historyStorage", () => {
+	beforeEach(() => {
+		vi.resetAllMocks()
+	})
+
+	describe("getHistoryFilePath", () => {
+		it("should return the correct path to cli-history.json", () => {
+			const result = getHistoryFilePath()
+			expect(result).toBe(path.join("/home/testuser", ".roo", "cli-history.json"))
+		})
+	})
+
+	describe("loadHistory", () => {
+		it("should return empty array when file does not exist", async () => {
+			const error = new Error("ENOENT") as NodeJS.ErrnoException
+			error.code = "ENOENT"
+			vi.mocked(fs.readFile).mockRejectedValue(error)
+
+			const result = await loadHistory()
+
+			expect(result).toEqual([])
+		})
+
+		it("should return entries from valid JSON file", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["first command", "second command", "third command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await loadHistory()
+
+			expect(result).toEqual(["first command", "second command", "third command"])
+		})
+
+		it("should return empty array for invalid JSON", async () => {
+			vi.mocked(fs.readFile).mockResolvedValue("not valid json")
+
+			// Suppress console.error for this test
+			const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+			const result = await loadHistory()
+
+			expect(result).toEqual([])
+			consoleSpy.mockRestore()
+		})
+
+		it("should filter out non-string entries", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["valid", 123, "also valid", null, ""],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await loadHistory()
+
+			expect(result).toEqual(["valid", "also valid"])
+		})
+
+		it("should return empty array when entries is not an array", async () => {
+			const mockData = {
+				version: 1,
+				entries: "not an array",
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await loadHistory()
+
+			expect(result).toEqual([])
+		})
+	})
+
+	describe("saveHistory", () => {
+		it("should create directory and save history", async () => {
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			await saveHistory(["command1", "command2"])
+
+			expect(fs.mkdir).toHaveBeenCalledWith(path.join("/home/testuser", ".roo"), { recursive: true })
+			expect(fs.writeFile).toHaveBeenCalled()
+
+			// Verify the content written
+			const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
+			const writtenContent = JSON.parse(writeCall?.[1] as string)
+			expect(writtenContent.version).toBe(1)
+			expect(writtenContent.entries).toEqual(["command1", "command2"])
+		})
+
+		it("should trim entries to MAX_HISTORY_ENTRIES", async () => {
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			// Create array larger than MAX_HISTORY_ENTRIES
+			const manyEntries = Array.from({ length: MAX_HISTORY_ENTRIES + 100 }, (_, i) => `command${i}`)
+
+			await saveHistory(manyEntries)
+
+			const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
+			const writtenContent = JSON.parse(writeCall?.[1] as string)
+			expect(writtenContent.entries.length).toBe(MAX_HISTORY_ENTRIES)
+			// Should keep the most recent entries (last 500)
+			expect(writtenContent.entries[0]).toBe(`command100`)
+			expect(writtenContent.entries[MAX_HISTORY_ENTRIES - 1]).toBe(`command${MAX_HISTORY_ENTRIES + 99}`)
+		})
+
+		it("should handle directory already exists error", async () => {
+			const error = new Error("EEXIST") as NodeJS.ErrnoException
+			error.code = "EEXIST"
+			vi.mocked(fs.mkdir).mockRejectedValue(error)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			// Should not throw
+			await expect(saveHistory(["command"])).resolves.not.toThrow()
+		})
+
+		it("should log warning on write error but not throw", async () => {
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied"))
+
+			const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+
+			await expect(saveHistory(["command"])).resolves.not.toThrow()
+			expect(consoleSpy).toHaveBeenCalledWith(
+				expect.stringContaining("Could not save CLI history"),
+				expect.any(String),
+			)
+
+			consoleSpy.mockRestore()
+		})
+	})
+
+	describe("addToHistory", () => {
+		it("should add new entry to history", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			const result = await addToHistory("new command")
+
+			expect(result).toEqual(["existing command", "new command"])
+		})
+
+		it("should not add empty strings", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await addToHistory("")
+
+			expect(result).toEqual(["existing command"])
+			expect(fs.writeFile).not.toHaveBeenCalled()
+		})
+
+		it("should not add whitespace-only strings", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing command"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await addToHistory("   ")
+
+			expect(result).toEqual(["existing command"])
+			expect(fs.writeFile).not.toHaveBeenCalled()
+		})
+
+		it("should not add consecutive duplicates", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["first", "second"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+
+			const result = await addToHistory("second")
+
+			expect(result).toEqual(["first", "second"])
+			expect(fs.writeFile).not.toHaveBeenCalled()
+		})
+
+		it("should add non-consecutive duplicates", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["first", "second"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			const result = await addToHistory("first")
+
+			expect(result).toEqual(["first", "second", "first"])
+		})
+
+		it("should trim whitespace from entry before adding", async () => {
+			const mockData = {
+				version: 1,
+				entries: ["existing"],
+			}
+			vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
+			vi.mocked(fs.mkdir).mockResolvedValue(undefined)
+			vi.mocked(fs.writeFile).mockResolvedValue(undefined)
+
+			const result = await addToHistory("  new command  ")
+
+			expect(result).toEqual(["existing", "new command"])
+		})
+	})
+
+	describe("MAX_HISTORY_ENTRIES", () => {
+		it("should be 500", () => {
+			expect(MAX_HISTORY_ENTRIES).toBe(500)
+		})
+	})
+})

+ 22 - 0
apps/cli/src/lib/storage/config-dir.ts

@@ -0,0 +1,22 @@
+import fs from "fs/promises"
+import os from "os"
+import path from "path"
+
+const CONFIG_DIR = path.join(os.homedir(), ".roo")
+
+export function getConfigDir(): string {
+	return CONFIG_DIR
+}
+
+export async function ensureConfigDir(): Promise<void> {
+	try {
+		await fs.mkdir(CONFIG_DIR, { recursive: true })
+	} catch (err) {
+		// Directory may already exist, that's fine.
+		const error = err as NodeJS.ErrnoException
+
+		if (error.code !== "EEXIST") {
+			throw err
+		}
+	}
+}

+ 72 - 0
apps/cli/src/lib/storage/credentials.ts

@@ -0,0 +1,72 @@
+import fs from "fs/promises"
+import path from "path"
+
+import { getConfigDir } from "./index.js"
+
+const CREDENTIALS_FILE = path.join(getConfigDir(), "cli-credentials.json")
+
+export interface Credentials {
+	token: string
+	createdAt: string
+	userId?: string
+	orgId?: string
+}
+
+export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise<void> {
+	await fs.mkdir(getConfigDir(), { recursive: true })
+
+	const credentials: Credentials = {
+		token,
+		createdAt: new Date().toISOString(),
+		userId: options?.userId,
+		orgId: options?.orgId,
+	}
+
+	await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
+		mode: 0o600, // Read/write for owner only
+	})
+}
+
+export async function loadToken(): Promise<string | null> {
+	try {
+		const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
+		const credentials: Credentials = JSON.parse(data)
+		return credentials.token
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			return null
+		}
+		throw error
+	}
+}
+
+export async function loadCredentials(): Promise<Credentials | null> {
+	try {
+		const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
+		return JSON.parse(data) as Credentials
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			return null
+		}
+		throw error
+	}
+}
+
+export async function clearToken(): Promise<void> {
+	try {
+		await fs.unlink(CREDENTIALS_FILE)
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
+			throw error
+		}
+	}
+}
+
+export async function hasToken(): Promise<boolean> {
+	const token = await loadToken()
+	return token !== null
+}
+
+export function getCredentialsPath(): string {
+	return CREDENTIALS_FILE
+}

+ 10 - 0
apps/cli/src/lib/storage/ephemeral.ts

@@ -0,0 +1,10 @@
+import path from "path"
+import os from "os"
+import fs from "fs"
+
+export async function createEphemeralStorageDir(): Promise<string> {
+	const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
+	const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
+	await fs.promises.mkdir(tmpDir, { recursive: true })
+	return tmpDir
+}

+ 109 - 0
apps/cli/src/lib/storage/history.ts

@@ -0,0 +1,109 @@
+import * as fs from "fs/promises"
+import * as path from "path"
+
+import { ensureConfigDir, getConfigDir } from "./config-dir.js"
+
+/** Maximum number of history entries to keep */
+export const MAX_HISTORY_ENTRIES = 500
+
+/** History file format version for future migrations */
+const HISTORY_VERSION = 1
+
+interface HistoryData {
+	version: number
+	entries: string[]
+}
+
+/**
+ * Get the path to the history file
+ */
+export function getHistoryFilePath(): string {
+	return path.join(getConfigDir(), "cli-history.json")
+}
+
+/**
+ * Load history entries from file
+ * Returns empty array if file doesn't exist or is invalid
+ */
+export async function loadHistory(): Promise<string[]> {
+	const filePath = getHistoryFilePath()
+
+	try {
+		const content = await fs.readFile(filePath, "utf-8")
+		const data: HistoryData = JSON.parse(content)
+
+		// Validate structure
+		if (!data || typeof data !== "object") {
+			return []
+		}
+
+		if (!Array.isArray(data.entries)) {
+			return []
+		}
+
+		// Filter to only valid strings
+		return data.entries.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
+	} catch (err) {
+		const error = err as NodeJS.ErrnoException
+		// File doesn't exist - that's expected on first run
+		if (error.code === "ENOENT") {
+			return []
+		}
+
+		// JSON parse error or other issue - log and return empty
+		console.error("Warning: Could not load CLI history:", error.message)
+		return []
+	}
+}
+
+/**
+ * Save history entries to file
+ * Creates the .roo directory if needed
+ * Trims to MAX_HISTORY_ENTRIES
+ */
+export async function saveHistory(entries: string[]): Promise<void> {
+	const filePath = getHistoryFilePath()
+
+	// Trim to max entries (keep most recent)
+	const trimmedEntries = entries.slice(-MAX_HISTORY_ENTRIES)
+
+	const data: HistoryData = {
+		version: HISTORY_VERSION,
+		entries: trimmedEntries,
+	}
+
+	try {
+		await ensureConfigDir()
+		await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf-8")
+	} catch (err) {
+		const error = err as NodeJS.ErrnoException
+		// Log but don't throw - history persistence is not critical
+		console.error("Warning: Could not save CLI history:", error.message)
+	}
+}
+
+/**
+ * Add a new entry to history and save
+ * Avoids adding consecutive duplicates or empty entries
+ * Returns the updated history array
+ */
+export async function addToHistory(entry: string): Promise<string[]> {
+	const trimmed = entry.trim()
+
+	// Don't add empty entries
+	if (!trimmed) {
+		return await loadHistory()
+	}
+
+	const history = await loadHistory()
+
+	// Don't add consecutive duplicates
+	if (history.length > 0 && history[history.length - 1] === trimmed) {
+		return history
+	}
+
+	const updated = [...history, trimmed]
+	await saveHistory(updated)
+
+	return updated.slice(-MAX_HISTORY_ENTRIES)
+}

+ 4 - 0
apps/cli/src/lib/storage/index.ts

@@ -0,0 +1,4 @@
+export * from "./config-dir.js"
+export * from "./settings.js"
+export * from "./credentials.js"
+export * from "./ephemeral.js"

+ 40 - 0
apps/cli/src/lib/storage/settings.ts

@@ -0,0 +1,40 @@
+import fs from "fs/promises"
+import path from "path"
+
+import type { CliSettings } from "@/types/index.js"
+
+import { getConfigDir } from "./index.js"
+
+export function getSettingsPath(): string {
+	return path.join(getConfigDir(), "cli-settings.json")
+}
+
+export async function loadSettings(): Promise<CliSettings> {
+	try {
+		const settingsPath = getSettingsPath()
+		const data = await fs.readFile(settingsPath, "utf-8")
+		return JSON.parse(data) as CliSettings
+	} catch (error) {
+		if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+			return {}
+		}
+
+		throw error
+	}
+}
+
+export async function saveSettings(settings: Partial<CliSettings>): Promise<void> {
+	const configDir = getConfigDir()
+	await fs.mkdir(configDir, { recursive: true })
+
+	const existing = await loadSettings()
+	const merged = { ...existing, ...settings }
+
+	await fs.writeFile(getSettingsPath(), JSON.stringify(merged, null, 2), {
+		mode: 0o600,
+	})
+}
+
+export async function resetOnboarding(): Promise<void> {
+	await saveSettings({ onboardingProviderChoice: undefined })
+}

+ 102 - 0
apps/cli/src/lib/utils/__tests__/commands.test.ts

@@ -0,0 +1,102 @@
+import {
+	type GlobalCommand,
+	type GlobalCommandAction,
+	GLOBAL_COMMANDS,
+	getGlobalCommand,
+	getGlobalCommandsForAutocomplete,
+} from "../commands.js"
+
+describe("globalCommands", () => {
+	describe("GLOBAL_COMMANDS", () => {
+		it("should contain the /new command", () => {
+			const newCommand = GLOBAL_COMMANDS.find((cmd) => cmd.name === "new")
+			expect(newCommand).toBeDefined()
+			expect(newCommand?.action).toBe("clearTask")
+			expect(newCommand?.description).toBe("Start a new task")
+		})
+
+		it("should have valid structure for all commands", () => {
+			for (const cmd of GLOBAL_COMMANDS) {
+				expect(cmd.name).toBeTruthy()
+				expect(typeof cmd.name).toBe("string")
+				expect(cmd.description).toBeTruthy()
+				expect(typeof cmd.description).toBe("string")
+				expect(cmd.action).toBeTruthy()
+				expect(typeof cmd.action).toBe("string")
+			}
+		})
+	})
+
+	describe("getGlobalCommand", () => {
+		it("should return the command when found", () => {
+			const cmd = getGlobalCommand("new")
+			expect(cmd).toBeDefined()
+			expect(cmd?.name).toBe("new")
+			expect(cmd?.action).toBe("clearTask")
+		})
+
+		it("should return undefined for unknown commands", () => {
+			const cmd = getGlobalCommand("unknown-command")
+			expect(cmd).toBeUndefined()
+		})
+
+		it("should be case-sensitive", () => {
+			const cmd = getGlobalCommand("NEW")
+			expect(cmd).toBeUndefined()
+		})
+	})
+
+	describe("getGlobalCommandsForAutocomplete", () => {
+		it("should return commands in autocomplete format", () => {
+			const commands = getGlobalCommandsForAutocomplete()
+			expect(commands.length).toBe(GLOBAL_COMMANDS.length)
+
+			for (const cmd of commands) {
+				expect(cmd.name).toBeTruthy()
+				expect(cmd.source).toBe("global")
+				expect(cmd.action).toBeTruthy()
+			}
+		})
+
+		it("should include the /new command with correct format", () => {
+			const commands = getGlobalCommandsForAutocomplete()
+			const newCommand = commands.find((cmd) => cmd.name === "new")
+
+			expect(newCommand).toBeDefined()
+			expect(newCommand?.description).toBe("Start a new task")
+			expect(newCommand?.source).toBe("global")
+			expect(newCommand?.action).toBe("clearTask")
+		})
+
+		it("should not include argumentHint for action commands", () => {
+			const commands = getGlobalCommandsForAutocomplete()
+			// Action commands don't have argument hints
+			for (const cmd of commands) {
+				expect(cmd).not.toHaveProperty("argumentHint")
+			}
+		})
+	})
+
+	describe("type safety", () => {
+		it("should have valid GlobalCommandAction types", () => {
+			// This test ensures the type is properly constrained
+			const validActions: GlobalCommandAction[] = ["clearTask"]
+
+			for (const cmd of GLOBAL_COMMANDS) {
+				expect(validActions).toContain(cmd.action)
+			}
+		})
+
+		it("should match GlobalCommand interface", () => {
+			const testCommand: GlobalCommand = {
+				name: "test",
+				description: "Test command",
+				action: "clearTask",
+			}
+
+			expect(testCommand.name).toBe("test")
+			expect(testCommand.description).toBe("Test command")
+			expect(testCommand.action).toBe("clearTask")
+		})
+	})
+})

+ 54 - 0
apps/cli/src/lib/utils/__tests__/extension.test.ts

@@ -0,0 +1,54 @@
+import fs from "fs"
+import path from "path"
+
+import { getDefaultExtensionPath } from "../extension.js"
+
+vi.mock("fs")
+
+describe("getDefaultExtensionPath", () => {
+	const originalEnv = process.env
+
+	beforeEach(() => {
+		vi.resetAllMocks()
+		// Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests.
+		process.env = { ...originalEnv }
+		delete process.env.ROO_EXTENSION_PATH
+	})
+
+	afterEach(() => {
+		process.env = originalEnv
+	})
+
+	it("should return monorepo path when extension.js exists there", () => {
+		const mockDirname = "/test/apps/cli/dist"
+		const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
+
+		vi.mocked(fs.existsSync).mockReturnValue(true)
+
+		const result = getDefaultExtensionPath(mockDirname)
+
+		expect(result).toBe(expectedMonorepoPath)
+		expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
+	})
+
+	it("should return package path when extension.js does not exist in monorepo path", () => {
+		const mockDirname = "/test/apps/cli/dist"
+		const expectedPackagePath = path.resolve(mockDirname, "../extension")
+
+		vi.mocked(fs.existsSync).mockReturnValue(false)
+
+		const result = getDefaultExtensionPath(mockDirname)
+
+		expect(result).toBe(expectedPackagePath)
+	})
+
+	it("should check monorepo path first", () => {
+		const mockDirname = "/some/path"
+		vi.mocked(fs.existsSync).mockReturnValue(false)
+
+		getDefaultExtensionPath(mockDirname)
+
+		const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
+		expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
+	})
+})

+ 128 - 0
apps/cli/src/lib/utils/__tests__/input.test.ts

@@ -0,0 +1,128 @@
+import type { Key } from "ink"
+
+import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js"
+
+function createKey(overrides: Partial<Key> = {}): Key {
+	return {
+		upArrow: false,
+		downArrow: false,
+		leftArrow: false,
+		rightArrow: false,
+		pageDown: false,
+		pageUp: false,
+		home: false,
+		end: false,
+		return: false,
+		escape: false,
+		ctrl: false,
+		shift: false,
+		tab: false,
+		backspace: false,
+		delete: false,
+		meta: false,
+		...overrides,
+	}
+}
+
+describe("globalInputSequences", () => {
+	describe("GLOBAL_INPUT_SEQUENCES registry", () => {
+		it("should have ctrl-c registered", () => {
+			const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-c")
+			expect(seq).toBeDefined()
+			expect(seq?.description).toContain("Exit")
+		})
+
+		it("should have ctrl-m registered", () => {
+			const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-m")
+			expect(seq).toBeDefined()
+			expect(seq?.description).toContain("mode")
+		})
+	})
+
+	describe("isGlobalInputSequence", () => {
+		describe("Ctrl+C detection", () => {
+			it("should match standard Ctrl+C", () => {
+				const result = isGlobalInputSequence("c", createKey({ ctrl: true }))
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-c")
+			})
+
+			it("should not match plain 'c' key", () => {
+				const result = isGlobalInputSequence("c", createKey())
+				expect(result).toBeUndefined()
+			})
+		})
+
+		describe("Ctrl+M detection", () => {
+			it("should match standard Ctrl+M", () => {
+				const result = isGlobalInputSequence("m", createKey({ ctrl: true }))
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-m")
+			})
+
+			it("should match CSI u encoding for Ctrl+M", () => {
+				const result = isGlobalInputSequence("\x1b[109;5u", createKey())
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-m")
+			})
+
+			it("should match input ending with CSI u sequence", () => {
+				const result = isGlobalInputSequence("[109;5u", createKey())
+				expect(result).toBeDefined()
+				expect(result?.id).toBe("ctrl-m")
+			})
+
+			it("should not match plain 'm' key", () => {
+				const result = isGlobalInputSequence("m", createKey())
+				expect(result).toBeUndefined()
+			})
+		})
+
+		it("should return undefined for non-global sequences", () => {
+			const result = isGlobalInputSequence("a", createKey())
+			expect(result).toBeUndefined()
+		})
+
+		it("should return undefined for regular text input", () => {
+			const result = isGlobalInputSequence("hello", createKey())
+			expect(result).toBeUndefined()
+		})
+	})
+
+	describe("matchesGlobalSequence", () => {
+		it("should return true for matching sequence ID", () => {
+			const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-c")
+			expect(result).toBe(true)
+		})
+
+		it("should return false for non-matching sequence ID", () => {
+			const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-m")
+			expect(result).toBe(false)
+		})
+
+		it("should return false for non-existent sequence ID", () => {
+			const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "non-existent")
+			expect(result).toBe(false)
+		})
+
+		it("should match ctrl-m with CSI u encoding", () => {
+			const result = matchesGlobalSequence("\x1b[109;5u", createKey(), "ctrl-m")
+			expect(result).toBe(true)
+		})
+	})
+
+	describe("extensibility", () => {
+		it("should have unique IDs for all sequences", () => {
+			const ids = GLOBAL_INPUT_SEQUENCES.map((s) => s.id)
+			const uniqueIds = new Set(ids)
+			expect(uniqueIds.size).toBe(ids.length)
+		})
+
+		it("should have descriptions for all sequences", () => {
+			for (const seq of GLOBAL_INPUT_SEQUENCES) {
+				expect(seq.description).toBeTruthy()
+				expect(seq.description.length).toBeGreaterThan(0)
+			}
+		})
+	})
+})

+ 68 - 0
apps/cli/src/lib/utils/__tests__/path.test.ts

@@ -0,0 +1,68 @@
+import { normalizePath, arePathsEqual } from "../path.js"
+
+// Helper to create platform-specific expected paths
+const expectedPath = (...segments: string[]) => {
+	// On Windows, path.normalize converts forward slashes to backslashes
+	// and paths like /Users become \Users (without a drive letter)
+	if (process.platform === "win32") {
+		return "\\" + segments.join("\\")
+	}
+
+	return "/" + segments.join("/")
+}
+
+describe("normalizePath", () => {
+	it("should remove trailing slashes", () => {
+		expect(normalizePath("/Users/test/project/")).toBe(expectedPath("Users", "test", "project"))
+		expect(normalizePath("/Users/test/project//")).toBe(expectedPath("Users", "test", "project"))
+	})
+
+	it("should handle paths without trailing slashes", () => {
+		expect(normalizePath("/Users/test/project")).toBe(expectedPath("Users", "test", "project"))
+	})
+
+	it("should normalize path separators", () => {
+		// path.normalize handles this
+		expect(normalizePath("/Users//test/project")).toBe(expectedPath("Users", "test", "project"))
+	})
+})
+
+describe("arePathsEqual", () => {
+	it("should return true for identical paths", () => {
+		expect(arePathsEqual("/Users/test/project", "/Users/test/project")).toBe(true)
+	})
+
+	it("should return true for paths differing only by trailing slash", () => {
+		expect(arePathsEqual("/Users/test/project", "/Users/test/project/")).toBe(true)
+		expect(arePathsEqual("/Users/test/project/", "/Users/test/project")).toBe(true)
+	})
+
+	it("should return false for undefined or empty paths", () => {
+		expect(arePathsEqual(undefined, "/Users/test/project")).toBe(false)
+		expect(arePathsEqual("/Users/test/project", undefined)).toBe(false)
+		expect(arePathsEqual(undefined, undefined)).toBe(false)
+		expect(arePathsEqual("", "/Users/test/project")).toBe(false)
+		expect(arePathsEqual("/Users/test/project", "")).toBe(false)
+	})
+
+	it("should return false for different paths", () => {
+		expect(arePathsEqual("/Users/test/project1", "/Users/test/project2")).toBe(false)
+		expect(arePathsEqual("/Users/test/project", "/Users/other/project")).toBe(false)
+	})
+
+	// Case sensitivity behavior depends on platform
+	if (process.platform === "darwin" || process.platform === "win32") {
+		it("should be case-insensitive on macOS/Windows", () => {
+			expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(true)
+			expect(arePathsEqual("/USERS/TEST/PROJECT", "/Users/test/project")).toBe(true)
+		})
+	} else {
+		it("should be case-sensitive on Linux", () => {
+			expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(false)
+		})
+	}
+
+	it("should handle paths with multiple trailing slashes", () => {
+		expect(arePathsEqual("/Users/test/project///", "/Users/test/project")).toBe(true)
+	})
+})

+ 34 - 0
apps/cli/src/lib/utils/__tests__/provider.test.ts

@@ -0,0 +1,34 @@
+import { getApiKeyFromEnv } from "../provider.js"
+
+describe("getApiKeyFromEnv", () => {
+	const originalEnv = process.env
+
+	beforeEach(() => {
+		// Reset process.env before each test.
+		process.env = { ...originalEnv }
+	})
+
+	afterEach(() => {
+		process.env = originalEnv
+	})
+
+	it("should return API key from environment variable for anthropic", () => {
+		process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
+		expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
+	})
+
+	it("should return API key from environment variable for openrouter", () => {
+		process.env.OPENROUTER_API_KEY = "test-openrouter-key"
+		expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
+	})
+
+	it("should return API key from environment variable for openai", () => {
+		process.env.OPENAI_API_KEY = "test-openai-key"
+		expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
+	})
+
+	it("should return undefined when API key is not set", () => {
+		delete process.env.ANTHROPIC_API_KEY
+		expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
+	})
+})

+ 62 - 0
apps/cli/src/lib/utils/commands.ts

@@ -0,0 +1,62 @@
+/**
+ * CLI-specific global slash commands
+ *
+ * These commands are handled entirely within the CLI and trigger actions
+ * by sending messages to the extension host. They are separate from the
+ * extension's built-in commands which expand into prompt content.
+ */
+
+/**
+ * Action types that can be triggered by global commands.
+ * Each action corresponds to a message type sent to the extension host.
+ */
+export type GlobalCommandAction = "clearTask"
+
+/**
+ * Definition of a CLI global command
+ */
+export interface GlobalCommand {
+	/** Command name (without the leading /) */
+	name: string
+	/** Description shown in the autocomplete picker */
+	description: string
+	/** Action to trigger when the command is executed */
+	action: GlobalCommandAction
+}
+
+/**
+ * CLI-specific global slash commands
+ * These commands trigger actions rather than expanding into prompt content.
+ */
+export const GLOBAL_COMMANDS: GlobalCommand[] = [
+	{
+		name: "new",
+		description: "Start a new task",
+		action: "clearTask",
+	},
+]
+
+/**
+ * Get a global command by name
+ */
+export function getGlobalCommand(name: string): GlobalCommand | undefined {
+	return GLOBAL_COMMANDS.find((cmd) => cmd.name === name)
+}
+
+/**
+ * Get global commands formatted for autocomplete
+ * Returns commands in the SlashCommandResult format expected by the autocomplete trigger
+ */
+export function getGlobalCommandsForAutocomplete(): Array<{
+	name: string
+	description?: string
+	source: "global" | "project" | "built-in"
+	action?: string
+}> {
+	return GLOBAL_COMMANDS.map((cmd) => ({
+		name: cmd.name,
+		description: cmd.description,
+		source: "global" as const,
+		action: cmd.action,
+	}))
+}

+ 67 - 0
apps/cli/src/lib/utils/context-window.ts

@@ -0,0 +1,67 @@
+import type { ProviderSettings } from "@roo-code/types"
+
+import type { RouterModels } from "@/ui/store.js"
+
+const DEFAULT_CONTEXT_WINDOW = 200_000
+
+/**
+ * Looks up the context window size for the current model from routerModels.
+ *
+ * @param routerModels - The router models data containing model info per provider
+ * @param apiConfiguration - The current API configuration with provider and model ID
+ * @returns The context window size, or DEFAULT_CONTEXT_WINDOW (200K) if not found
+ */
+export function getContextWindow(routerModels: RouterModels | null, apiConfiguration: ProviderSettings | null): number {
+	if (!routerModels || !apiConfiguration) {
+		return DEFAULT_CONTEXT_WINDOW
+	}
+
+	const provider = apiConfiguration.apiProvider
+	const modelId = getModelIdForProvider(apiConfiguration)
+
+	if (!provider || !modelId) {
+		return DEFAULT_CONTEXT_WINDOW
+	}
+
+	const providerModels = routerModels[provider]
+	const modelInfo = providerModels?.[modelId]
+
+	return modelInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
+}
+
+/**
+ * Gets the model ID from the API configuration based on the provider type.
+ *
+ * Different providers store their model ID in different fields of ProviderSettings.
+ */
+function getModelIdForProvider(config: ProviderSettings): string | undefined {
+	switch (config.apiProvider) {
+		case "openrouter":
+			return config.openRouterModelId
+		case "ollama":
+			return config.ollamaModelId
+		case "lmstudio":
+			return config.lmStudioModelId
+		case "openai":
+			return config.openAiModelId
+		case "requesty":
+			return config.requestyModelId
+		case "litellm":
+			return config.litellmModelId
+		case "deepinfra":
+			return config.deepInfraModelId
+		case "huggingface":
+			return config.huggingFaceModelId
+		case "unbound":
+			return config.unboundModelId
+		case "vercel-ai-gateway":
+			return config.vercelAiGatewayModelId
+		case "io-intelligence":
+			return config.ioIntelligenceModelId
+		default:
+			// For anthropic, bedrock, vertex, gemini, xai, groq, etc.
+			return config.apiModelId
+	}
+}
+
+export { DEFAULT_CONTEXT_WINDOW }

+ 33 - 0
apps/cli/src/lib/utils/extension.ts

@@ -0,0 +1,33 @@
+import path from "path"
+import fs from "fs"
+
+/**
+ * Get the default path to the extension bundle.
+ * This assumes the CLI is installed alongside the built extension.
+ *
+ * @param dirname - The __dirname equivalent for the calling module
+ */
+export function getDefaultExtensionPath(dirname: string): string {
+	// Check for environment variable first (set by install script)
+	if (process.env.ROO_EXTENSION_PATH) {
+		const envPath = process.env.ROO_EXTENSION_PATH
+
+		if (fs.existsSync(path.join(envPath, "extension.js"))) {
+			return envPath
+		}
+	}
+
+	// __dirname is apps/cli/dist when bundled
+	// The extension is at src/dist (relative to monorepo root)
+	// So from apps/cli/dist, we need to go ../../../src/dist
+	const monorepoPath = path.resolve(dirname, "../../../src/dist")
+
+	// Try monorepo path first (for development)
+	if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
+		return monorepoPath
+	}
+
+	// Fallback: when installed via curl script, extension is at ../extension
+	const packagePath = path.resolve(dirname, "../extension")
+	return packagePath
+}

+ 122 - 0
apps/cli/src/lib/utils/input.ts

@@ -0,0 +1,122 @@
+/**
+ * Global Input Sequences Registry
+ *
+ * This module centralizes the definition of input sequences that should be
+ * handled at the App level (or other top-level components) and ignored by
+ * child components like MultilineTextInput.
+ *
+ * When adding new global shortcuts:
+ * 1. Add the sequence definition to GLOBAL_INPUT_SEQUENCES
+ * 2. The App.tsx useInput handler should check for and handle the sequence
+ * 3. Child components automatically ignore these via isGlobalInputSequence()
+ */
+
+import type { Key } from "ink"
+
+/**
+ * Definition of a global input sequence
+ */
+export interface GlobalInputSequence {
+	/** Unique identifier for the sequence */
+	id: string
+	/** Human-readable description */
+	description: string
+	/**
+	 * Matcher function - returns true if the input matches this sequence.
+	 * @param input - The raw input string from useInput
+	 * @param key - The parsed key object from useInput
+	 */
+	matches: (input: string, key: Key) => boolean
+}
+
+/**
+ * Registry of all global input sequences that should be handled at the App level
+ * and ignored by child components (like MultilineTextInput).
+ *
+ * Add new global shortcuts here to ensure they're properly handled throughout
+ * the application.
+ */
+export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [
+	{
+		id: "ctrl-c",
+		description: "Exit application (with confirmation)",
+		matches: (input, key) => key.ctrl && input === "c",
+	},
+	{
+		id: "ctrl-m",
+		description: "Cycle through modes",
+		matches: (input, key) => {
+			// Standard Ctrl+M detection
+			if (key.ctrl && input === "m") return true
+			// CSI u encoding: ESC [ 109 ; 5 u (kitty keyboard protocol)
+			// 109 = 'm' ASCII code, 5 = Ctrl modifier
+			if (input === "\x1b[109;5u") return true
+			if (input.endsWith("[109;5u")) return true
+			return false
+		},
+	},
+	{
+		id: "ctrl-t",
+		description: "Toggle TODO list viewer",
+		matches: (input, key) => {
+			// Standard Ctrl+T detection
+			if (key.ctrl && input === "t") return true
+			// CSI u encoding: ESC [ 116 ; 5 u (kitty keyboard protocol)
+			// 116 = 't' ASCII code, 5 = Ctrl modifier
+			if (input === "\x1b[116;5u") return true
+			if (input.endsWith("[116;5u")) return true
+			return false
+		},
+	},
+	// Add more global sequences here as needed:
+	// {
+	//   id: "ctrl-n",
+	//   description: "New task",
+	//   matches: (input, key) => key.ctrl && input === "n",
+	// },
+]
+
+/**
+ * Check if an input matches any global input sequence.
+ *
+ * Use this in child components (like MultilineTextInput) to determine
+ * if input should be ignored because it will be handled by a parent component.
+ *
+ * @param input - The raw input string from useInput
+ * @param key - The parsed key object from useInput
+ * @returns The matching GlobalInputSequence, or undefined if no match
+ *
+ * @example
+ * ```tsx
+ * useInput((input, key) => {
+ *   // Ignore inputs handled at App level
+ *   if (isGlobalInputSequence(input, key)) {
+ *     return
+ *   }
+ *   // Handle component-specific input...
+ * })
+ * ```
+ */
+export function isGlobalInputSequence(input: string, key: Key): GlobalInputSequence | undefined {
+	return GLOBAL_INPUT_SEQUENCES.find((seq) => seq.matches(input, key))
+}
+
+/**
+ * Check if an input matches a specific global input sequence by ID.
+ *
+ * @param input - The raw input string from useInput
+ * @param key - The parsed key object from useInput
+ * @param id - The sequence ID to check for
+ * @returns true if the input matches the specified sequence
+ *
+ * @example
+ * ```tsx
+ * if (matchesGlobalSequence(input, key, "ctrl-m")) {
+ *   // Handle mode cycling
+ * }
+ * ```
+ */
+export function matchesGlobalSequence(input: string, key: Key, id: string): boolean {
+	const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === id)
+	return seq ? seq.matches(input, key) : false
+}

+ 33 - 0
apps/cli/src/lib/utils/onboarding.ts

@@ -0,0 +1,33 @@
+import { createElement } from "react"
+
+import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js"
+import { login } from "@/commands/index.js"
+import { saveSettings } from "@/lib/storage/index.js"
+
+export async function runOnboarding(): Promise<OnboardingResult> {
+	const { render } = await import("ink")
+	const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js")
+
+	return new Promise<OnboardingResult>((resolve) => {
+		const onSelect = async (choice: OnboardingProviderChoice) => {
+			await saveSettings({ onboardingProviderChoice: choice })
+
+			app.unmount()
+
+			console.log("")
+
+			if (choice === OnboardingProviderChoice.Roo) {
+				const { success: authenticated } = await login()
+				await saveSettings({ onboardingProviderChoice: choice })
+				resolve({ choice: OnboardingProviderChoice.Roo, authenticated, skipped: false })
+			} else {
+				console.log("Using your own API key.")
+				console.log("Set your API key via --api-key or environment variable.")
+				console.log("")
+				resolve({ choice: OnboardingProviderChoice.Byok, skipped: false })
+			}
+		}
+
+		const app = render(createElement(OnboardingScreen, { onSelect }))
+	})
+}

+ 35 - 0
apps/cli/src/lib/utils/path.ts

@@ -0,0 +1,35 @@
+import * as path from "path"
+
+/**
+ * Normalize a path by removing trailing slashes and converting separators.
+ * This handles cross-platform path comparison issues.
+ */
+export function normalizePath(p: string): string {
+	// Remove trailing slashes
+	let normalized = p.replace(/[/\\]+$/, "")
+	// Convert to consistent separators using path.normalize
+	normalized = path.normalize(normalized)
+	return normalized
+}
+
+/**
+ * Compare two paths for equality, handling:
+ * - Trailing slashes
+ * - Path separator differences
+ * - Case sensitivity (case-insensitive on Windows/macOS)
+ */
+export function arePathsEqual(path1?: string, path2?: string): boolean {
+	if (!path1 || !path2) {
+		return false
+	}
+
+	const normalizedPath1 = normalizePath(path1)
+	const normalizedPath2 = normalizePath(path2)
+
+	// On Windows and macOS, file paths are case-insensitive
+	if (process.platform === "win32" || process.platform === "darwin") {
+		return normalizedPath1.toLowerCase() === normalizedPath2.toLowerCase()
+	}
+
+	return normalizedPath1 === normalizedPath2
+}

+ 61 - 0
apps/cli/src/lib/utils/provider.ts

@@ -0,0 +1,61 @@
+import { RooCodeSettings } from "@roo-code/types"
+
+import type { SupportedProvider } from "@/types/index.js"
+
+const envVarMap: Record<SupportedProvider, string> = {
+	anthropic: "ANTHROPIC_API_KEY",
+	"openai-native": "OPENAI_API_KEY",
+	gemini: "GOOGLE_API_KEY",
+	openrouter: "OPENROUTER_API_KEY",
+	"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
+	roo: "ROO_API_KEY",
+}
+
+export function getEnvVarName(provider: SupportedProvider): string {
+	return envVarMap[provider]
+}
+
+export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined {
+	const envVar = getEnvVarName(provider)
+	return process.env[envVar]
+}
+
+export function getProviderSettings(
+	provider: SupportedProvider,
+	apiKey: string | undefined,
+	model: string | undefined,
+): RooCodeSettings {
+	const config: RooCodeSettings = { apiProvider: provider }
+
+	switch (provider) {
+		case "anthropic":
+			if (apiKey) config.apiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		case "openai-native":
+			if (apiKey) config.openAiNativeApiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		case "gemini":
+			if (apiKey) config.geminiApiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		case "openrouter":
+			if (apiKey) config.openRouterApiKey = apiKey
+			if (model) config.openRouterModelId = model
+			break
+		case "vercel-ai-gateway":
+			if (apiKey) config.vercelAiGatewayApiKey = apiKey
+			if (model) config.vercelAiGatewayModelId = model
+			break
+		case "roo":
+			if (apiKey) config.rooApiKey = apiKey
+			if (model) config.apiModelId = model
+			break
+		default:
+			if (apiKey) config.apiKey = apiKey
+			if (model) config.apiModelId = model
+	}
+
+	return config
+}

+ 6 - 0
apps/cli/src/lib/utils/version.ts

@@ -0,0 +1,6 @@
+import { createRequire } from "module"
+
+const require = createRequire(import.meta.url)
+const packageJson = require("../package.json")
+
+export const VERSION = packageJson.version

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است