瀏覽代碼

Merge main into provider-selection-fix

marius-kilocode 3 周之前
父節點
當前提交
d765a70f28
共有 100 個文件被更改,包括 4172 次插入474 次删除
  1. 14 0
      .changeset/agent-manager-image-paste.md
  2. 10 0
      .changeset/approval-feedback-fix.md
  3. 5 0
      .changeset/cli-continue-reliability.md
  4. 10 0
      .changeset/cli-image-text-paste-loader.md
  5. 5 0
      .changeset/cozy-shirts-try.md
  6. 5 0
      .changeset/cuddly-candles-protect.md
  7. 0 5
      .changeset/empty-places-doubt.md
  8. 5 0
      .changeset/fix-cli-diff-partial-markers.md
  9. 0 7
      .changeset/fix-cmdv-image-paste-regression.md
  10. 5 0
      .changeset/fix-zod-function-api.md
  11. 5 0
      .changeset/friendly-jars-yawn.md
  12. 0 5
      .changeset/loud-cities-punch.md
  13. 5 0
      .changeset/metal-sheep-fry.md
  14. 5 0
      .changeset/remove-clipboard-reading.md
  15. 5 0
      .changeset/simplify-cli-readme-dev-process.md
  16. 1 1
      .devcontainer/Dockerfile
  17. 3 3
      .github/actions/ai-release-notes/action.yml
  18. 5 0
      .github/dependabot.yml
  19. 5 5
      .github/workflows/build-cli.yml
  20. 3 3
      .github/workflows/changeset-release.yml
  21. 18 18
      .github/workflows/cli-publish.yml
  22. 21 21
      .github/workflows/code-qa.yml
  23. 3 3
      .github/workflows/docusaurus-build.yml
  24. 2 2
      .github/workflows/evals.yml
  25. 13 13
      .github/workflows/marketplace-publish.yml
  26. 5 5
      .github/workflows/storybook-playwright-snapshot.yml
  27. 3 3
      .github/workflows/update-contributors.yml
  28. 58 38
      .kilocodemodes
  29. 1 1
      .nvmrc
  30. 1 1
      .tool-versions
  31. 27 0
      CHANGELOG.md
  32. 1 1
      DEVELOPMENT.md
  33. 3 1
      apps/kilocode-docs/docs/agent-behavior/skills.md
  34. 136 0
      apps/kilocode-docs/docs/cli.md
  35. 145 0
      apps/kilocode-docs/docs/contributing/architecture/model-o11y.md
  36. 0 1
      apps/kilocode-docs/docs/providers/cerebras.md
  37. 3 0
      cli/.gitignore
  38. 43 0
      cli/CHANGELOG.md
  39. 2 2
      cli/Dockerfile
  40. 1 46
      cli/README.md
  41. 1 1
      cli/npm-shrinkwrap.dist.json
  42. 2 2
      cli/package.dist.json
  43. 3 2
      cli/package.json
  44. 148 0
      cli/src/__tests__/agent-manager-no-config.test.ts
  45. 19 2
      cli/src/__tests__/attach-flag.test.ts
  46. 60 11
      cli/src/auth/index.ts
  47. 6 0
      cli/src/auth/providers/factory.ts
  48. 7 10
      cli/src/auth/providers/kilocode/shared.ts
  49. 7 4
      cli/src/auth/utils/terminal.ts
  50. 47 19
      cli/src/cli.ts
  51. 667 0
      cli/src/commands/__tests__/custom.test.ts
  52. 189 0
      cli/src/commands/custom.ts
  53. 1 1
      cli/src/commands/index.ts
  54. 2 109
      cli/src/commands/models-api.ts
  55. 58 0
      cli/src/config/__tests__/persistence.test.ts
  56. 25 1
      cli/src/config/persistence.ts
  57. 29 3
      cli/src/index.ts
  58. 85 0
      cli/src/media/__tests__/image-utils.test.ts
  59. 72 0
      cli/src/media/image-utils.ts
  60. 111 0
      cli/src/services/models/fetcher.ts
  61. 71 0
      cli/src/state/atoms/__tests__/effects-ci-completion.test.ts
  62. 31 14
      cli/src/state/atoms/__tests__/keyboard.test.ts
  63. 227 0
      cli/src/state/atoms/__tests__/taskHistory.test.ts
  64. 6 0
      cli/src/state/atoms/ci.ts
  65. 16 4
      cli/src/state/atoms/effects.ts
  66. 62 14
      cli/src/state/atoms/keyboard.ts
  67. 126 0
      cli/src/state/hooks/__tests__/useCIMode.test.tsx
  68. 182 0
      cli/src/state/hooks/__tests__/useSessionCost.test.ts
  69. 97 4
      cli/src/state/hooks/__tests__/useStdinJsonHandler.test.ts
  70. 4 0
      cli/src/state/hooks/index.ts
  71. 25 4
      cli/src/state/hooks/useCIMode.ts
  72. 55 0
      cli/src/state/hooks/useSessionCost.ts
  73. 62 5
      cli/src/state/hooks/useStdinJsonHandler.ts
  74. 48 12
      cli/src/ui/UI.tsx
  75. 15 1
      cli/src/ui/components/StatusBar.tsx
  76. 14 4
      cli/src/ui/components/StatusIndicator.tsx
  77. 8 0
      cli/src/ui/components/__tests__/StatusBar.test.tsx
  78. 4 3
      cli/src/ui/messages/extension/ExtensionMessageRow.tsx
  79. 451 0
      cli/src/ui/messages/extension/__tests__/diff.test.ts
  80. 144 9
      cli/src/ui/messages/extension/diff.ts
  81. 11 30
      cli/src/ui/messages/extension/say/SayApiReqStartedMessage.tsx
  82. 53 0
      cli/src/ui/utils/__tests__/resumePrompt.test.ts
  83. 51 0
      cli/src/ui/utils/__tests__/terminalCapabilities.test.ts
  84. 23 0
      cli/src/ui/utils/resumePrompt.ts
  85. 19 0
      cli/src/ui/utils/terminalCapabilities.ts
  86. 134 0
      cli/src/utils/__tests__/context.test.ts
  87. 71 0
      cli/src/utils/__tests__/env-loader.test.ts
  88. 8 0
      cli/src/utils/context.ts
  89. 4 4
      cli/src/utils/env-loader.ts
  90. 9 5
      cli/src/validation/attachments.ts
  91. 1 1
      package.json
  92. 7 0
      packages/core-schemas/CHANGELOG.md
  93. 2 2
      packages/core-schemas/package.json
  94. 1 0
      packages/core-schemas/src/agent-manager/types.ts
  95. 2 2
      packages/core-schemas/src/auth/kilocode.ts
  96. 1 1
      packages/core-schemas/src/config/cli-config.ts
  97. 1 1
      packages/core-schemas/src/config/provider.ts
  98. 2 2
      packages/core-schemas/src/mcp/server.ts
  99. 2 1
      packages/core-schemas/src/messages/extension.ts
  100. 1 1
      packages/evals/README.md

+ 14 - 0
.changeset/agent-manager-image-paste.md

@@ -0,0 +1,14 @@
+---
+"kilo-code": minor
+"@kilocode/cli": minor
+"@kilocode/core-schemas": patch
+---
+
+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

+ 10 - 0
.changeset/approval-feedback-fix.md

@@ -0,0 +1,10 @@
+---
+"kilo-code": patch
+---
+
+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)

+ 5 - 0
.changeset/cli-continue-reliability.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Improve --continue flag reliability by replacing fixed 2-second timeout with Promise-based response handling

+ 10 - 0
.changeset/cli-image-text-paste-loader.md

@@ -0,0 +1,10 @@
+---
+"@kilocode/cli": patch
+---
+
+Fix missing visual feedback and input blocking during paste operations
+
+- Display "Pasting image..." loader when pasting images via Cmd+V/Ctrl+V
+- Display "Pasting text..." loader when pasting large text (10+ lines)
+- Block keyboard input during paste operations to prevent concurrent writes
+- Support multiple concurrent paste operations with counter-based tracking

+ 5 - 0
.changeset/cozy-shirts-try.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+fix session restore race that triggered premature exit

+ 5 - 0
.changeset/cuddly-candles-protect.md

@@ -0,0 +1,5 @@
+---
+"kilocode-docs": patch
+---
+
+Remove deprecated zai-glm-4.6 model from Cerebras provider due to deprecation

+ 0 - 5
.changeset/empty-places-doubt.md

@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-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.

+ 5 - 0
.changeset/fix-cli-diff-partial-markers.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Fix CLI diff display showing partial SEARCH/REPLACE markers and git merge conflict markers during streaming

+ 0 - 7
.changeset/fix-cmdv-image-paste-regression.md

@@ -1,7 +0,0 @@
----
-"@kilocode/cli": patch
----
-
-Fix Cmd+V image paste regression in VSCode terminal
-
-Restores the ability to paste images using Cmd+V in VSCode terminal, which was broken in #4916. VSCode sends empty bracketed paste sequences for Cmd+V (unlike regular terminals that send key events), so we need to check the clipboard for images when receiving an empty paste.

+ 5 - 0
.changeset/fix-zod-function-api.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/core-schemas": patch
+---
+
+Fix Zod function API usage in pollingOptionsSchema

+ 5 - 0
.changeset/friendly-jars-yawn.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Improved the reliability of the read_file tool when using Claude models

+ 0 - 5
.changeset/loud-cities-punch.md

@@ -1,5 +0,0 @@
----
-"kilo-code": minor
----
-
-feat(ovhcloud): Add native function calling support

+ 5 - 0
.changeset/metal-sheep-fry.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": minor
+---
+
+add custom commands support

+ 5 - 0
.changeset/remove-clipboard-reading.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Remove clipboard reading from chat autocomplete

+ 5 - 0
.changeset/simplify-cli-readme-dev-process.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": patch
+---
+
+Simplify CLI README development instructions to use 4-step process

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

+ 3 - 3
.github/workflows/docusaurus-build.yml

@@ -1,7 +1,7 @@
 name: Docusaurus Build Check
 
 env:
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
 
 on:
@@ -19,7 +19,7 @@ jobs:
         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"

+ 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

+ 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"

+ 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

+ 27 - 0
CHANGELOG.md

@@ -1,5 +1,32 @@
 # kilo-code
 
+## 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

+ 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
 

+ 3 - 1
apps/kilocode-docs/docs/agent-behavior/skills.md

@@ -34,7 +34,9 @@ Skills are loaded from multiple locations, allowing both personal skills and pro
 
 ### Global Skills (User-Level)
 
-Located in `~/.kilocode/skills/`:
+Global skills are located in the `.kilocode` directory within your Home directory. 
+* Mac and Linux: `~/.kilocode/skills/`
+* Windows: `\Users\<yourUser>\.kilocode\`
 
 ```
 ~/.kilocode/

+ 136 - 0
apps/kilocode-docs/docs/cli.md

@@ -141,6 +141,142 @@ There are community efforts to build and share agent skills. Some resources incl
 - [Skills Marketplace](https://skillsmp.com/) - Community marketplace of skills
 - [Skill Specification](https://agentskills.io/home) - Agent Skills specification
 
+## Custom Commands
+
+Custom commands allow you to create reusable slash commands that execute predefined prompts with argument substitution. They provide a convenient way to streamline repetitive tasks and standardize workflows.
+
+Custom commands are discovered from:
+
+- **Global commands**: `~/.kilocode/commands/` (available in all projects)
+- **Project commands**: `.kilocode/commands/` (project-specific)
+
+Commands are simple markdown files with YAML frontmatter for configuration.
+
+### Creating a Custom Command
+
+1. Create the commands directory:
+
+    ```bash
+    mkdir -p ~/.kilocode/commands # mkdir %USERPROFILE%\.kilocode\commands on windows
+    ```
+
+2. Create a markdown file (e.g., `component.md`):
+
+    ```markdown
+    ---
+    description: Create a new React component
+    arguments:
+        - ComponentName
+    ---
+
+    Create a new React component named $1.
+    Include:
+
+    - Proper TypeScript typing
+    - Basic component structure
+    - Export statement
+    - A simple props interface if appropriate
+
+    Place it in the appropriate directory based on the project structure.
+    ```
+
+3. Use the command in your CLI session:
+
+    ```bash
+    /component Button
+    ```
+
+### Frontmatter Options
+
+Custom commands support the following frontmatter fields:
+
+- **`description`** (optional): Short description shown in `/help`
+- **`arguments`** (optional): List of argument names for documentation
+- **`mode`** (optional): Automatically switch to this mode when running the command
+- **`model`** (optional): Automatically switch to this model when running the command
+
+### Argument Substitution
+
+Commands support powerful argument substitution:
+
+- **`$ARGUMENTS`**: All arguments joined with spaces
+- **`$1`, `$2`, `$3`, etc.**: Individual positional arguments
+
+**Example:**
+
+```markdown
+---
+description: Create a file with content
+arguments:
+    - filename
+    - content
+---
+
+Create a new file named $1 with the following content:
+
+$2
+```
+
+Usage: `/createfile app.ts "console.log('Hello')"`
+
+### Mode and Model Switching
+
+Commands can automatically switch modes and models:
+
+```markdown
+---
+description: Run tests with coverage
+mode: code
+model: anthropic/claude-3-5-sonnet-20241022
+---
+
+Run the full test suite with coverage report and show any failures.
+Focus on the failing tests and suggest fixes.
+```
+
+When you run `/test`, it will automatically switch to code mode and use the specified model.
+
+### Example Commands
+
+**Initialize project documentation:**
+
+```markdown
+---
+description: Analyze codebase and create AGENTS.md
+mode: code
+---
+
+Please analyze this codebase and create an AGENTS.md file containing:
+
+1. Build/lint/test commands - especially for running a single test
+2. Code style guidelines including imports, formatting, types, naming conventions
+
+Focus on project-specific, non-obvious information discovered by reading files.
+```
+
+**Refactor code:**
+
+```markdown
+---
+description: Refactor code for better quality
+arguments:
+    - filepath
+---
+
+Refactor $1 to improve:
+
+- Code readability
+- Performance
+- Maintainability
+- Type safety
+
+Explain the changes you make and why they improve the code.
+```
+
+### Command Priority
+
+Project-specific commands override global commands with the same name, allowing you to customize behavior per project while maintaining sensible defaults globally.
+
 ## Checkpoint Management
 
 Kilo Code automatically creates checkpoints as you work, allowing you to revert to previous states in your project's history.

+ 145 - 0
apps/kilocode-docs/docs/contributing/architecture/model-o11y.md

@@ -0,0 +1,145 @@
+---
+sidebar_position: 11
+title: "Agent Observability"
+---
+
+# Kilo Code - Agent Observability
+
+## Problem Statement
+
+Agentic coding systems like Kilo Code operate with significant autonomy, executing multi-step tasks that involve LLM inference, tool execution, file manipulation, and external API calls. These systems mix traditional systems observability (i.e. request/response) with agentic behavior (i.e. planning, reasoning, and tool use).
+
+At the lower level, we can observe the system as a traditional API, but at the higher level, we need to observe the agent's behavior and the quality of its outputs.
+
+Some examples of customer-facing error modes:
+
+- Model API calls may be slow or fail due to rate limits, network issues, or model unavailability
+- Model API calls may produce invalid JSON or malformed responses
+- An agent may get stuck in a loop, repeatedly attempting the same failing operation
+- Sessions may degrade gradually as context windows fill up
+- The agent may complete a task technically but produce incorrect or unhelpful output
+- Users may abandon sessions out of frustration without explicit error signals
+
+All of these contribute to the overall reliability and user experience of the system.
+
+## Goals
+
+1. Detect and alert on acute incidents within minutes
+2. Surface slow-burn degradations within hours
+3. Facilitate root cause analysis when issues occur
+4. Track quality and efficiency trends over time
+5. Build a foundation for continuous improvement of the agent
+
+**Non-goals for this proposal:**
+
+- Automated remediation
+- A/B testing infrastructure
+
+## Proposed Approach
+
+Focus on the lower-level systems observability first, then build up to higher-level agentic behavior observability.
+
+## Phase 1: Systems Observability
+
+**Objective:** Establish awareness and alerting for hard failures.
+
+This phase focuses on systems metrics we can capture with minimal changes, providing immediate operational visibility.
+
+### Phase 1a: LLM observability and alerting
+
+#### Metrics to Capture
+
+Capture these metrics per LLM API call:
+
+- Provider
+- Model
+- Tool
+- Latency
+- Success / Failure
+- Error type and message (if failed)
+- Token counts
+
+#### Dashboards
+
+Common dashboards which offer filtering based on provider, model, and tool:
+
+- Error rate
+- Latency
+- Token usage
+
+#### Alerting
+
+Implement [multi-window, multi-burn-rate alerting](https://sre.google/workbook/alerting-on-slos/) against error budgets:
+
+| Window | Burn Rate | Action | Use Case            |
+| ------ | --------- | ------ | ------------------- |
+| 5 min  | 14.4x     | Page   | Major Outage              |
+| 30 min | 6x        | Page   | Incident            |
+| 6 hr   | 1x        | Ticket | Change in behavior |
+
+Paging should **only occur on Recommended Models when using the Kilo Gateway**. All other alerts should be tickets, and some may be configured to be ignored.
+
+**Initial alert conditions:**
+
+- LLM API error rate exceeds SLO (per tool/model/provider)
+- Tool error rate exceeds SLO (per tool/model/provider)
+- p50/p90 latency exceeds SLO (per tool/model/provider)
+
+### Phase 1b: Session metrics
+
+#### Metrics to Capture
+
+**Per-session (aggregated at session close or timeout):**
+
+- Session duration
+- Time from user input to first model response
+- Total turns/steps
+- Total tool calls by tool type
+- Total errors by error type
+- Total tokens consumed
+- Termination reason (user closed, timeout, explicit completion, error)
+
+#### Alerting
+
+None.
+
+## Phase 2: Agent Tool Usage
+
+**Objective:** Detect how agents are using tools in a given session.
+
+### Metrics to Capture
+
+**Loop and repetition detection:**
+
+- Count of identical tool calls within a session (same tool + same arguments)
+- Count of identical failing tool calls (same tool + same arguments + same error)
+- Detection of oscillation patterns (alternating between two states)
+
+**Progress indicators:**
+
+- Unique files touched per session
+- Unique tools used per session
+- Ratio of repeated to unique operations
+
+### Alerting
+
+None to start, we will learn.
+
+## Phase 3: Session Outcome Tracking
+
+**Objective:** Understand whether sessions are successful from the user's perspective.
+
+Hard errors and behavior metrics tell us about failures, but we also need signal on overall session health.
+
+### Metrics to Capture
+
+**Explicit signals:**
+
+- User feedback (thumbs up/down) rate and sentiment
+- User abandonment patterns (session ends mid-task without completion signal)
+
+**Implicit signals:**
+
+May require LLM analysis of session transcripts to detect:
+
+- Session termination classification (completed, abandoned, errored, timed out)

+ 0 - 1
apps/kilocode-docs/docs/providers/cerebras.md

@@ -20,7 +20,6 @@ Cerebras is known for their ultra-fast AI inference powered by the Cerebras CS-3
 Kilo Code supports the following Cerebras models:
 
 - `gpt-oss-120b` (Default) – High-performance open-source model optimized for fast inference
-- `zai-glm-4.6` – Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.
 - `zai-glm-4.7` – Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
 
 Refer to the [Cerebras documentation](https://docs.cerebras.ai/) for detailed information on model capabilities and performance characteristics.

+ 3 - 0
cli/.gitignore

@@ -4,3 +4,6 @@ node_modules
 # Integration test temp files
 integration-tests/**/*.log
 **/kilocode-cli-tests/
+
+# Unit test temp files
+test-temp/

+ 43 - 0
cli/CHANGELOG.md

@@ -1,5 +1,48 @@
 # @kilocode/cli
 
+## 0.23.1
+
+### Patch Changes
+
+- [#5164](https://github.com/Kilo-Org/kilocode/pull/5164) [`d63378c`](https://github.com/Kilo-Org/kilocode/commit/d63378c5698a0117177d86143185cb46c66f3c73) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Show auth prompt instead of timeout when CLI not configured in Agent Manager
+
+- [#5169](https://github.com/Kilo-Org/kilocode/pull/5169) [`18a9da4`](https://github.com/Kilo-Org/kilocode/commit/18a9da440f4905ce45c80bb0fa622767880de6c6) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fixed escape sequences appearing as raw text on Windows cmd.exe
+
+## 0.23.0
+
+### Minor Changes
+
+- [#5084](https://github.com/Kilo-Org/kilocode/pull/5084) [`f0c79d2`](https://github.com/Kilo-Org/kilocode/commit/f0c79d27c4952e0359ebc97d41bb50aebd2ef577) Thanks [@montanaflynn](https://github.com/montanaflynn)! - Improved CLI welcome flow: added interactive model selection list using `@inquirer/prompts`, updated provider selection to display a scrollable list, and refactored model fetching logic into a reusable service.
+
+### Patch Changes
+
+- [#5116](https://github.com/Kilo-Org/kilocode/pull/5116) [`cf00ed8`](https://github.com/Kilo-Org/kilocode/commit/cf00ed870c1af723924177372da1054411e269cd) Thanks [@PeterDaveHello](https://github.com/PeterDaveHello)! - Make .env file optional in CLI - users can configure via KILO\_\* environment variables instead
+
+## 0.22.2
+
+### Patch Changes
+
+- [#5113](https://github.com/Kilo-Org/kilocode/pull/5113) [`6d04a15`](https://github.com/Kilo-Org/kilocode/commit/6d04a150383af75ed42b954fc3c42e9e010bbed9) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix CLI crash when config file is empty or contains invalid JSON
+
+## 0.22.1
+
+### Patch Changes
+
+- [#5098](https://github.com/Kilo-Org/kilocode/pull/5098) [`e811ebe`](https://github.com/Kilo-Org/kilocode/commit/e811ebe287f187bac11239fddfab7067f428872d) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Show total session cost in status bar instead of per-request costs. Remove "API Request in progress" messages for cleaner UI.
+
+- [#5100](https://github.com/Kilo-Org/kilocode/pull/5100) [`a49868e`](https://github.com/Kilo-Org/kilocode/commit/a49868e17842d252a9a28d61aa0683267e8e3020) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix CLI context indicator showing incorrect values by skipping placeholder api_req_started messages
+
+- [#5104](https://github.com/Kilo-Org/kilocode/pull/5104) [`15a8d77`](https://github.com/Kilo-Org/kilocode/commit/15a8d77fdbe78314b448714e9812fc0857393cf5) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Fix CLI interactive prompts (arrow key navigation) not working on Windows
+
+    The inquirer v13+ upgrade introduced stricter TTY raw mode requirements. This fix ensures raw mode is properly enabled before inquirer prompts, restoring arrow key navigation in list selections like provider choice during `kilocode auth`.
+
+- [#5092](https://github.com/Kilo-Org/kilocode/pull/5092) [`42cdb11`](https://github.com/Kilo-Org/kilocode/commit/42cdb11b77552cb87fce9ee591bd68cbe419c3be) Thanks [@Drilmo](https://github.com/Drilmo)! - Fix Cmd+V image paste regression in VSCode terminal
+
+    Restores the ability to paste images using Cmd+V in VSCode terminal, which was broken in #4916. VSCode sends empty bracketed paste sequences for Cmd+V (unlike regular terminals that send key events), so we need to check the clipboard for images when receiving an empty paste.
+
+- Updated dependencies [[`b2e2630`](https://github.com/Kilo-Org/kilocode/commit/b2e26304e562e516383fbf95a3fdc668d88e1487)]:
+    - @kilocode/[email protected]
+
 ## 0.22.0
 
 ### Minor Changes

+ 2 - 2
cli/Dockerfile

@@ -6,7 +6,7 @@ ARG VERSION
 # ==========================================
 # 1. Alpine Base 
 # ==========================================
-FROM node:20.19.2-alpine AS alpine_base
+FROM node:20.20.0-alpine AS alpine_base
 
 # Install dependencies
 RUN apk add --no-cache \
@@ -32,7 +32,7 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
 # ==========================================
 # 2. Debian Base
 # ==========================================
-FROM node:20.19.2-bookworm-slim AS debian_base
+FROM node:20.20.0-bookworm-slim AS debian_base
 
 # Install system dependencies
 RUN apt-get update && apt-get install -y \

+ 1 - 46
cli/README.md

@@ -276,49 +276,4 @@ This instructs the AI to proceed without user input.
 
 ## Local Development
 
-### Getting Started
-
-To build and run the CLI locally off your branch:
-
-#### Build the VS Code extension
-
-```shell
-cd src
-pnpm bundle
-pnpm vsix
-pnpm vsix:unpacked
-cd ..
-```
-
-#### Install CLI dependencies
-
-```shell
-cd cli
-pnpm install
-pnpm deps:install
-```
-
-#### Build the CLI
-
-```shell
-pnpm clean
-pnpm clean:kilocode
-pnpm copy:kilocode
-pnpm build
-```
-
-#### Configure CLI settings
-
-```shell
-pnpm start config
-```
-
-#### Run the built CLI
-
-```shell
-pnpm start
-```
-
-### Using DevTools
-
-In order to run the CLI with devtools, add `DEV=true` to your `pnpm start` command, and then run `npx react-devtools` to show the devtools inspector.
+See [Development Guide](cli/docs/DEVELOPMENT.md) for setup instructions.

+ 1 - 1
cli/npm-shrinkwrap.dist.json

@@ -119,7 +119,7 @@
 				"kilocode": "index.js"
 			},
 			"engines": {
-				"node": ">=20.19.2"
+				"node": ">=20.20.0"
 			}
 		},
 		"node_modules/@alcalzone/ansi-tokenize": {

+ 2 - 2
cli/package.dist.json

@@ -1,6 +1,6 @@
 {
 	"name": "@kilocode/cli",
-	"version": "0.22.0",
+	"version": "0.23.1",
 	"description": "Terminal User Interface for Kilo Code",
 	"type": "module",
 	"main": "index.js",
@@ -110,7 +110,7 @@
 		}
 	},
 	"engines": {
-		"node": ">=20.19.2"
+		"node": ">=20.20.0"
 	},
 	"keywords": ["cli", "tui", "terminal", "ai", "assistant", "kilocode", "kilo", "ink"],
 	"author": {

+ 3 - 2
cli/package.json

@@ -1,6 +1,6 @@
 {
 	"name": "@kilocode/cli",
-	"version": "0.22.0",
+	"version": "0.23.1",
 	"description": "Terminal User Interface for Kilo Code",
 	"type": "module",
 	"main": "dist/index.js",
@@ -39,6 +39,7 @@
 		"@aws-sdk/client-bedrock-runtime": "^3.966.0",
 		"@aws-sdk/credential-providers": "^3.966.0",
 		"@google/genai": "^1.35.0",
+		"@inquirer/prompts": "^8.2.0",
 		"@kilocode/core-schemas": "workspace:^",
 		"@lmstudio/sdk": "^1.5.0",
 		"@mistralai/mistralai": "^1.11.0",
@@ -165,7 +166,7 @@
 		"vitest": "^4.0.16"
 	},
 	"engines": {
-		"node": ">=20.19.2"
+		"node": ">=20.20.0"
 	},
 	"keywords": [
 		"cli",

+ 148 - 0
cli/src/__tests__/agent-manager-no-config.test.ts

@@ -0,0 +1,148 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
+
+/**
+ * Tests for CLI behavior when spawned from Agent Manager without configuration.
+ *
+ * When the CLI is spawned from the Agent Manager (KILO_PLATFORM=agent-manager)
+ * and no config exists, it should output a JSON welcome message with instructions
+ * instead of trying to show the interactive auth wizard (which would hang without TTY).
+ */
+
+// Mock the config persistence module
+vi.mock("../config/persistence.js", () => ({
+	configExists: vi.fn(),
+	loadConfig: vi.fn(),
+}))
+
+// Mock the env-config module
+vi.mock("../config/env-config.js", () => ({
+	envConfigExists: vi.fn(),
+	getMissingEnvVars: vi.fn(),
+}))
+
+// Mock the auth wizard to ensure it's not called
+vi.mock("../auth/index.js", () => ({
+	default: vi.fn(),
+}))
+
+// Mock the logs service
+vi.mock("../services/logs.js", () => ({
+	logs: {
+		info: vi.fn(),
+		debug: vi.fn(),
+		error: vi.fn(),
+		warn: vi.fn(),
+	},
+}))
+
+describe("Agent Manager No Config Behavior", () => {
+	let originalEnv: NodeJS.ProcessEnv
+	let consoleLogSpy: ReturnType<typeof vi.spyOn>
+	let processExitSpy: ReturnType<typeof vi.spyOn>
+
+	beforeEach(() => {
+		// Save original environment
+		originalEnv = { ...process.env }
+
+		// Spy on console.log to capture JSON output
+		consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
+
+		// Spy on process.exit to prevent actual exit
+		processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
+			throw new Error("process.exit called")
+		})
+	})
+
+	afterEach(() => {
+		// Restore original environment
+		process.env = originalEnv
+
+		// Restore spies
+		consoleLogSpy.mockRestore()
+		processExitSpy.mockRestore()
+
+		vi.clearAllMocks()
+	})
+
+	it("should output JSON welcome message with instructions when KILO_PLATFORM=agent-manager and no config", async () => {
+		// Set up environment for agent-manager mode
+		process.env.KILO_PLATFORM = "agent-manager"
+
+		// Mock config functions to return no config
+		const { configExists } = await import("../config/persistence.js")
+		const { envConfigExists } = await import("../config/env-config.js")
+		vi.mocked(configExists).mockResolvedValue(false)
+		vi.mocked(envConfigExists).mockReturnValue(false)
+
+		// We can't easily test the full CLI entry point, but we can test the logic
+		// by checking that the welcome message format matches what CliOutputParser expects
+		const welcomeMessage = {
+			type: "welcome",
+			timestamp: Date.now(),
+			metadata: {
+				welcomeOptions: {
+					instructions: ["Configuration required: No provider configured."],
+				},
+			},
+		}
+
+		// Verify the structure matches what the Agent Manager expects
+		expect(welcomeMessage.type).toBe("welcome")
+		expect(welcomeMessage.metadata.welcomeOptions.instructions).toBeInstanceOf(Array)
+		expect(welcomeMessage.metadata.welcomeOptions.instructions.length).toBeGreaterThan(0)
+
+		// Verify the JSON can be parsed correctly
+		const jsonString = JSON.stringify(welcomeMessage)
+		const parsed = JSON.parse(jsonString)
+		expect(parsed.type).toBe("welcome")
+		expect(parsed.metadata.welcomeOptions.instructions).toContain("Configuration required: No provider configured.")
+	})
+
+	it("should have instructions that trigger cli_configuration_error in Agent Manager", () => {
+		// The welcome message format that the CLI outputs
+		const welcomeMessage = {
+			type: "welcome",
+			timestamp: Date.now(),
+			metadata: {
+				welcomeOptions: {
+					instructions: ["Configuration required: No provider configured."],
+				},
+			},
+		}
+
+		// Simulate what CliOutputParser.toStreamEvent does for welcome events
+		const parsed = welcomeMessage
+		const metadata = parsed.metadata as Record<string, unknown>
+		const welcomeOptions = metadata?.welcomeOptions as Record<string, unknown>
+		const instructions = welcomeOptions?.instructions as string[]
+
+		// Verify instructions are present and non-empty (this triggers cli_configuration_error)
+		expect(instructions).toBeDefined()
+		expect(Array.isArray(instructions)).toBe(true)
+		expect(instructions.length).toBeGreaterThan(0)
+
+		// The CliProcessHandler.extractConfigErrorFromWelcome joins instructions with newlines
+		const configurationError = instructions.join("\n")
+		expect(configurationError).toBe("Configuration required: No provider configured.")
+	})
+
+	it("should not include showInstructions flag (only instructions array matters)", () => {
+		// The simplified welcome message format
+		const welcomeMessage = {
+			type: "welcome",
+			timestamp: Date.now(),
+			metadata: {
+				welcomeOptions: {
+					instructions: ["Configuration required: No provider configured."],
+				},
+			},
+		}
+
+		// Verify showInstructions is not present (it's not needed)
+		const welcomeOptions = welcomeMessage.metadata.welcomeOptions as Record<string, unknown>
+		expect(welcomeOptions.showInstructions).toBeUndefined()
+
+		// Only the instructions array matters for triggering the error
+		expect(welcomeOptions.instructions).toBeDefined()
+	})
+})

+ 19 - 2
cli/src/__tests__/attach-flag.test.ts

@@ -66,12 +66,12 @@ describe("CLI --attach flag", () => {
 	})
 
 	describe("Mode validation", () => {
-		it("should reject --attach without --auto", () => {
+		it("should reject --attach without --auto or --json-io", () => {
 			const result = validateAttachRequiresAuto({
 				attach: ["./screenshot.png"],
 			})
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe("Error: --attach option requires --auto flag")
+			expect(result.error).toBe("Error: --attach option requires --auto or --json-io flag")
 		})
 
 		it("should accept --attach with --auto flag", () => {
@@ -82,6 +82,23 @@ describe("CLI --attach flag", () => {
 			expect(result.valid).toBe(true)
 		})
 
+		it("should accept --attach with --json-io flag", () => {
+			const result = validateAttachRequiresAuto({
+				attach: ["./screenshot.png"],
+				jsonIo: true,
+			})
+			expect(result.valid).toBe(true)
+		})
+
+		it("should accept --attach with both --auto and --json-io flags", () => {
+			const result = validateAttachRequiresAuto({
+				attach: ["./screenshot.png"],
+				auto: true,
+				jsonIo: true,
+			})
+			expect(result.valid).toBe(true)
+		})
+
 		it("should accept when no attachments are provided", () => {
 			const result = validateAttachRequiresAuto({})
 			expect(result.valid).toBe(true)

+ 60 - 11
cli/src/auth/index.ts

@@ -1,6 +1,14 @@
-import inquirer from "inquirer"
+import { select } from "@inquirer/prompts"
 import { loadConfig, saveConfig, CLIConfig } from "../config/index.js"
 import { authProviders } from "./providers/index.js"
+import { fetchRouterModels } from "../services/models/fetcher.js"
+import {
+	getModelsByProvider,
+	providerSupportsModelList,
+	getModelIdKey,
+	sortModelsByPreference,
+} from "../constants/providers/models.js"
+import type { ProviderName } from "../types/messages.js"
 import { withRawMode } from "./utils/terminal.js"
 
 /**
@@ -20,16 +28,13 @@ export default async function authWizard(): Promise<void> {
 		// Prompt user to select a provider
 		// Use withRawMode to ensure arrow key navigation works in list prompts
 		// (required for inquirer v13+ which uses @inquirer/prompts internally)
-		const { selectedProvider } = await withRawMode(() =>
-			inquirer.prompt<{ selectedProvider: string }>([
-				{
-					type: "list",
-					name: "selectedProvider",
-					message: "Please select which provider you would like to use:",
-					choices: providerChoices,
-					loop: false,
-				},
-			]),
+		const selectedProvider = await withRawMode(() =>
+			select({
+				message: "Select an AI provider:",
+				choices: providerChoices,
+				loop: false,
+				pageSize: process.stdout.rows ? Math.min(20, process.stdout.rows - 2) : 10,
+			}),
 		)
 
 		// Find the selected provider
@@ -52,6 +57,50 @@ export default async function authWizard(): Promise<void> {
 			process.exit(1)
 		}
 
+		// Model Selection
+		const providerId = authResult.providerConfig.provider as ProviderName
+
+		let routerModels = null
+		if (providerSupportsModelList(providerId)) {
+			console.log("\nFetching available models...")
+			try {
+				routerModels = await fetchRouterModels(authResult.providerConfig)
+			} catch (_) {
+				console.warn("Failed to fetch models, using defaults if available.")
+			}
+		}
+
+		const { models, defaultModel } = getModelsByProvider({
+			provider: providerId,
+			routerModels,
+			kilocodeDefaultModel: "",
+		})
+
+		const modelIds = sortModelsByPreference(models)
+
+		if (modelIds.length > 0) {
+			const modelChoices = modelIds.map((id) => {
+				const model = models[id]
+				return {
+					name: model?.displayName || id,
+					value: id,
+				}
+			})
+
+			const selectedModel = await withRawMode(() =>
+				select({
+					message: "Select a model to use:",
+					choices: modelChoices,
+					default: defaultModel,
+					loop: false,
+					pageSize: 10,
+				}),
+			)
+
+			const modelKey = getModelIdKey(providerId)
+			authResult.providerConfig[modelKey] = selectedModel
+		}
+
 		// Save the configuration
 		const newConfig: CLIConfig = {
 			...config.config,

+ 6 - 0
cli/src/auth/providers/factory.ts

@@ -3,6 +3,7 @@ import type { ProviderName } from "../../types/messages.js"
 import { PROVIDER_REQUIRED_FIELDS } from "../../constants/providers/validation.js"
 import { FIELD_REGISTRY, isOptionalField, getProviderDefaultModel } from "../../constants/providers/settings.js"
 import { PROVIDER_LABELS } from "../../constants/providers/labels.js"
+import { isModelField } from "../../constants/providers/models.js"
 import inquirer from "inquirer"
 import { withRawMode } from "../utils/terminal.js"
 
@@ -20,6 +21,11 @@ function createGenericAuthFunction(providerName: ProviderName) {
 
 		// Build prompts from required fields
 		for (const field of requiredFields) {
+			// Skip model fields as they are handled by the main auth wizard
+			if (isModelField(field) || field === "apiModelId") {
+				continue
+			}
+
 			const fieldMeta = FIELD_REGISTRY[field]
 			if (!fieldMeta) {
 				// Skip fields without metadata

+ 7 - 10
cli/src/auth/providers/kilocode/shared.ts

@@ -1,7 +1,7 @@
 import { getApiUrl } from "@roo-code/types"
 import { openRouterDefaultModelId } from "@roo-code/types"
 import { z } from "zod"
-import inquirer from "inquirer"
+import { select } from "@inquirer/prompts"
 import { logs } from "../../../services/logs.js"
 import type { KilocodeOrganization, KilocodeProfileData } from "../../types.js"
 import { withRawMode } from "../../utils/terminal.js"
@@ -135,15 +135,12 @@ export async function promptOrganizationSelection(organizations: KilocodeOrganiz
 
 	// Use withRawMode to ensure arrow key navigation works in list prompts
 	// (required for inquirer v13+ which uses @inquirer/prompts internally)
-	const { accountType } = await withRawMode(() =>
-		inquirer.prompt<{ accountType: string }>([
-			{
-				type: "list",
-				name: "accountType",
-				message: "Select account type:",
-				choices: accountChoices,
-			},
-		]),
+	const accountType = await withRawMode(() =>
+		select({
+			message: "Select account type:",
+			choices: accountChoices,
+			loop: false,
+		}),
 	)
 
 	// Return organization ID if not personal

+ 7 - 4
cli/src/auth/utils/terminal.ts

@@ -37,8 +37,10 @@ export function ensureRawMode(): () => void {
 	if (!wasRawMode) {
 		try {
 			process.stdin.setRawMode(true)
-		} catch {
-			// If setting raw mode fails, just continue without it
+		} catch (error) {
+			// If setting raw mode fails, log and continue without it
+			// The CLI will fall back to non-interactive mode
+			console.debug("Failed to enable terminal raw mode:", error)
 			return () => {}
 		}
 	}
@@ -48,8 +50,9 @@ export function ensureRawMode(): () => void {
 		if (!wasRawMode && process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
 			try {
 				process.stdin.setRawMode(false)
-			} catch {
-				// Ignore errors during cleanup
+			} catch (error) {
+				// Log but don't throw - terminal state restoration is best-effort
+				console.debug("Failed to restore terminal raw mode:", error)
 			}
 		}
 	}

+ 47 - 19
cli/src/cli.ts

@@ -6,6 +6,7 @@ import { createExtensionService, ExtensionService } from "./services/extension.j
 import { App } from "./ui/App.js"
 import { logs } from "./services/logs.js"
 import { initializeSyntaxHighlighter } from "./ui/utils/syntaxHighlight.js"
+import { supportsTitleSetting } from "./ui/utils/terminalCapabilities.js"
 import { extensionServiceAtom } from "./state/atoms/service.js"
 import { initializeServiceEffectAtom } from "./state/atoms/effects.js"
 import { loadConfigAtom, mappedExtensionStateAtom, providersAtom, saveConfigAtom } from "./state/atoms/config.js"
@@ -14,8 +15,8 @@ import { requestRouterModelsAtom } from "./state/atoms/actions.js"
 import { loadHistoryAtom } from "./state/atoms/history.js"
 import {
 	addPendingRequestAtom,
+	removePendingRequestAtom,
 	TaskHistoryData,
-	taskHistoryDataAtom,
 	updateTaskHistoryFiltersAtom,
 } from "./state/atoms/taskHistory.js"
 import { sendWebviewMessageAtom } from "./state/atoms/actions.js"
@@ -78,7 +79,9 @@ export class CLI {
 			// Set terminal title - use process.cwd() in parallel mode to show original directory
 			const titleWorkspace = this.options.parallel ? process.cwd() : this.options.workspace || process.cwd()
 			const folderName = `${basename(titleWorkspace)}${(await isGitWorktree(this.options.workspace || "")) ? " (git worktree)" : ""}`
-			process.stdout.write(`\x1b]0;Kilo Code - ${folderName}\x07`)
+			if (supportsTitleSetting()) {
+				process.stdout.write(`\x1b]0;Kilo Code - ${folderName}\x07`)
+			}
 
 			// Create Jotai store
 			this.store = createStore()
@@ -275,8 +278,15 @@ export class CLI {
 				logs.debug("SessionManager workspace directory set", "CLI", { workspace })
 
 				if (this.options.session) {
+					// Set flag BEFORE restoring session to prevent race condition
+					// The session restoration triggers async state updates that may contain
+					// historical completion_result messages. Without this flag set first,
+					// the CI exit logic may trigger before the prompt can execute.
+					this.store.set(taskResumedViaContinueOrSessionAtom, true)
 					await this.sessionService?.restoreSession(this.options.session)
 				} else if (this.options.fork) {
+					// Set flag BEFORE forking session (same race condition as restore)
+					this.store.set(taskResumedViaContinueOrSessionAtom, true)
 					logs.info("Forking session from share ID", "CLI", { shareId: this.options.fork })
 					await this.sessionService?.forkSession(this.options.fork)
 				}
@@ -646,24 +656,37 @@ export class CLI {
 				favoritesOnly: false,
 			})
 
-			// Send task history request to extension
-			await this.store.set(sendWebviewMessageAtom, {
-				type: "taskHistoryRequest",
-				payload: {
-					requestId: Date.now().toString(),
-					workspace: "current",
-					sort: "newest",
-					favoritesOnly: false,
-					pageIndex: 0,
-				},
+			// Create a unique request ID for tracking the response
+			const requestId = `${Date.now()}-${Math.random()}`
+			const TASK_HISTORY_TIMEOUT_MS = 5000
+
+			// Fetch task history with Promise-based response handling
+			const taskHistoryData = await new Promise<TaskHistoryData>((resolve, reject) => {
+				// Set up timeout
+				const timeout = setTimeout(() => {
+					this.store!.set(removePendingRequestAtom, requestId)
+					reject(new Error(`Task history request timed out after ${TASK_HISTORY_TIMEOUT_MS}ms`))
+				}, TASK_HISTORY_TIMEOUT_MS)
+
+				// Register the pending request - it will be resolved when the response arrives
+				this.store!.set(addPendingRequestAtom, { requestId, resolve, reject, timeout })
+
+				// Send task history request to extension
+				this.store!.set(sendWebviewMessageAtom, {
+					type: "taskHistoryRequest",
+					payload: {
+						requestId,
+						workspace: "current",
+						sort: "newest",
+						favoritesOnly: false,
+						pageIndex: 0,
+					},
+				}).catch((err) => {
+					this.store!.set(removePendingRequestAtom, requestId)
+					reject(err)
+				})
 			})
 
-			// Wait for the data to arrive (the response will update taskHistoryDataAtom through effects)
-			await new Promise((resolve) => setTimeout(resolve, 2000))
-
-			// Get the task history data
-			const taskHistoryData = this.store.get(taskHistoryDataAtom)
-
 			if (!taskHistoryData || !taskHistoryData.historyItems || taskHistoryData.historyItems.length === 0) {
 				logs.warn("No previous tasks found for workspace", "CLI", { workspace })
 				console.error("\nNo previous tasks found for this workspace. Please start a new conversation.\n")
@@ -693,7 +716,12 @@ export class CLI {
 			logs.info("Task resume initiated", "CLI", { taskId: lastTask.id, task: lastTask.task })
 		} catch (error) {
 			logs.error("Failed to resume conversation", "CLI", { error, workspace })
-			console.error("\nFailed to resume conversation. Please try starting a new conversation.\n")
+			const errorMessage = error instanceof Error ? error.message : String(error)
+			if (errorMessage.includes("timed out")) {
+				console.error("\nFailed to fetch task history (request timed out). Please try again.\n")
+			} else {
+				console.error("\nFailed to resume conversation. Please try starting a new conversation.\n")
+			}
 			process.exit(1)
 		}
 	}

+ 667 - 0
cli/src/commands/__tests__/custom.test.ts

@@ -0,0 +1,667 @@
+/**
+ * Tests for custom commands
+ */
+
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import { substituteArguments, getCustomCommands, initializeCustomCommands } from "../custom.js"
+import * as path from "path"
+import { createMockContext } from "./helpers/mockContext.js"
+import type { Command } from "../core/types.js"
+import type { Dirent } from "fs"
+
+/** Minimal mock for fs.Dirent used in readdir results */
+type MockDirent = Pick<Dirent, "name" | "isFile" | "isDirectory">
+
+// Hoist mock functions so they're available during module mocking
+const { mockReaddir, mockReadFile, mockHomedir, mockRegister } = vi.hoisted(() => ({
+	mockReaddir: vi.fn<(path: string) => Promise<MockDirent[]>>(),
+	mockReadFile: vi.fn<(path: string) => Promise<string>>(),
+	mockHomedir: vi.fn<() => string>(),
+	mockRegister: vi.fn(),
+}))
+
+vi.mock("fs/promises", () => ({
+	default: {
+		readdir: mockReaddir,
+		readFile: mockReadFile,
+	},
+	readdir: mockReaddir,
+	readFile: mockReadFile,
+}))
+
+vi.mock("os", () => ({
+	default: {
+		homedir: mockHomedir,
+	},
+	homedir: mockHomedir,
+}))
+
+vi.mock("../core/registry.js", () => ({
+	commandRegistry: {
+		register: mockRegister,
+		get: vi.fn(() => undefined), // Return undefined to indicate command doesn't exist
+	},
+}))
+
+vi.mock("../services/logs.js", () => ({
+	logs: {
+		debug: vi.fn(),
+		warn: vi.fn(),
+	},
+}))
+
+describe("Custom Commands", () => {
+	describe("substituteArguments", () => {
+		it("should replace $ARGUMENTS with all arguments", () => {
+			const content = "Process $ARGUMENTS"
+			const args = ["file1.txt", "file2.txt", "file3.txt"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Process file1.txt file2.txt file3.txt")
+		})
+
+		it("should replace positional arguments $1, $2, $3", () => {
+			const content = "Copy $1 to $2 with mode $3"
+			const args = ["source.txt", "dest.txt", "overwrite"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Copy source.txt to dest.txt with mode overwrite")
+		})
+
+		it("should handle both $ARGUMENTS and positional arguments", () => {
+			const content = "First arg is $1, all args are: $ARGUMENTS"
+			const args = ["alpha", "beta", "gamma"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("First arg is alpha, all args are: alpha beta gamma")
+		})
+
+		it("should handle empty arguments", () => {
+			const content = "No args: $ARGUMENTS and $1"
+			const args: string[] = []
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("No args:  and $1")
+		})
+
+		it("should not replace undefined positional arguments", () => {
+			const content = "Args: $1 $2 $3"
+			const args = ["first"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Args: first $2 $3")
+		})
+
+		it("should handle content with no placeholders", () => {
+			const content = "Plain text content"
+			const args = ["arg1", "arg2"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Plain text content")
+		})
+
+		it("should not replace currency amounts with decimals like $1.50", () => {
+			const content = "The price is $1.50 for item $1"
+			const args = ["widget"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("The price is $1.50 for item widget")
+		})
+
+		it("should not replace $1 when it's part of $100", () => {
+			const content = "Price is $100 and description is $1"
+			const args = ["expensive item"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Price is $100 and description is expensive item")
+		})
+
+		it("should not replace $2 when it's part of $25.99", () => {
+			const content = "Cost: $25.99, item: $2, quantity: $1"
+			const args = ["5", "hammer"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Cost: $25.99, item: hammer, quantity: 5")
+		})
+
+		it("should replace $1 at end of sentence with period", () => {
+			const content = "Process file $1."
+			const args = ["test.txt"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Process file test.txt.")
+		})
+
+		it("should handle multiple currency amounts and placeholders", () => {
+			const content = "Budget is $1000.50, allocate $1 to $2 with $3 priority"
+			const args = ["$500", "project-a", "high"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Budget is $1000.50, allocate $500 to project-a with high priority")
+		})
+
+		it("should not replace positional args in larger numbers", () => {
+			const content = "Total: $123.45, items: $1, $2, $3"
+			const args = ["apple", "banana", "cherry"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Total: $123.45, items: apple, banana, cherry")
+		})
+
+		it("should handle edge case with $10 when args has one element", () => {
+			const content = "Price is $10 and name is $1"
+			const args = ["product"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Price is $10 and name is product")
+		})
+	})
+
+	describe("getCustomCommands", () => {
+		const mockCwd = "/mock/project"
+		const mockHomeDir = "/mock/home"
+
+		beforeEach(() => {
+			vi.clearAllMocks()
+			mockHomedir.mockReturnValue(mockHomeDir)
+		})
+
+		it("should load commands from global directory", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockImplementation(async (dirPath: string) => {
+				if (dirPath === path.join(mockHomeDir, ".kilocode", "commands")) {
+					return mockFiles
+				}
+				throw new Error("ENOENT")
+			})
+
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+arguments: [arg1, arg2]
+---
+Test content with $1 and $2`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("test-command")
+			expect(commands[0].description).toBe("Test command")
+			expect(commands[0].arguments).toEqual(["arg1", "arg2"])
+			expect(commands[0].content).toBe("Test content with $1 and $2")
+		})
+
+		it("should load commands from project directory with priority", async () => {
+			const globalFiles: MockDirent[] = [
+				{
+					name: "shared-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			const projectFiles: MockDirent[] = [
+				{
+					name: "shared-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "project-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockImplementation(async (dirPath: string) => {
+				if (dirPath === path.join(mockHomeDir, ".kilocode", "commands")) {
+					return globalFiles
+				}
+				if (dirPath === path.join(mockCwd, ".kilocode", "commands")) {
+					return projectFiles
+				}
+				throw new Error("ENOENT")
+			})
+
+			mockReadFile.mockImplementation(async (filePath: string) => {
+				if (filePath.includes("project")) {
+					return `---
+description: Project version
+---
+Project content`
+				}
+				return `---
+description: Global version
+---
+Global content`
+			})
+
+			const commands = await getCustomCommands(mockCwd)
+
+			// Should have 2 commands total (shared-command from project overrides global)
+			expect(commands).toHaveLength(2)
+
+			const sharedCommand = commands.find((c) => c.name === "shared-command")
+			expect(sharedCommand?.description).toBe("Project version")
+			expect(sharedCommand?.content).toBe("Project content")
+		})
+
+		it("should skip non-markdown files", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "readme.txt",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "config.json",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Valid command
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("command")
+		})
+
+		it("should handle missing directories gracefully", async () => {
+			mockReaddir.mockRejectedValue(new Error("ENOENT"))
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(0)
+		})
+
+		it("should parse mode and model from frontmatter", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+mode: plan
+model: opus
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands[0].mode).toBe("plan")
+			expect(commands[0].model).toBe("opus")
+		})
+
+		it("should skip files with invalid command names starting with --", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "--test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "valid.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("valid")
+		})
+
+		it("should skip files with invalid command names starting with -", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "-test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "valid-name.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("valid-name")
+		})
+
+		it("should skip files with special characters in names", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test!.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "[email protected]",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "test$var.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "valid123.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("valid123")
+		})
+
+		it("should accept valid command names with alphanumeric and hyphens", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "my-command-123.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "ABC-xyz-999.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(3)
+			expect(commands.map((c) => c.name).sort()).toEqual(["ABC-xyz-999", "my-command-123", "test-command"])
+		})
+	})
+
+	describe("initializeCustomCommands", () => {
+		const mockCwd = "/mock/project"
+
+		beforeEach(() => {
+			vi.clearAllMocks()
+			mockHomedir.mockReturnValue("/mock/home")
+		})
+
+		it("should register custom commands", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+---
+Test content`)
+
+			await initializeCustomCommands(mockCwd)
+
+			expect(mockRegister).toHaveBeenCalledTimes(1)
+			expect(mockRegister).toHaveBeenCalledWith(
+				expect.objectContaining({
+					name: "test",
+					description: "Test command",
+					category: "chat",
+					priority: 3,
+				}),
+			)
+		})
+
+		it("should handle errors gracefully", async () => {
+			mockReaddir.mockRejectedValue(new Error("Permission denied"))
+
+			// Should not throw
+			await expect(initializeCustomCommands(mockCwd)).resolves.not.toThrow()
+		})
+
+		it("should not register commands if none found", async () => {
+			mockReaddir.mockRejectedValue(new Error("ENOENT"))
+
+			await initializeCustomCommands(mockCwd)
+
+			expect(mockRegister).not.toHaveBeenCalled()
+		})
+	})
+
+	describe("custom command handler", () => {
+		const mockCwd = "/mock/project"
+
+		beforeEach(() => {
+			vi.clearAllMocks()
+			mockHomedir.mockReturnValue("/mock/home")
+		})
+
+		async function getRegisteredHandler(): Promise<Command["handler"]> {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test-cmd.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+mode: architect
+model: opus
+arguments: [file, destination]
+---
+Process $1 to $2 with $ARGUMENTS`)
+
+			await initializeCustomCommands(mockCwd)
+
+			expect(mockRegister).toHaveBeenCalledTimes(1)
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			return registeredCommand.handler
+		}
+
+		it("should call setMode when custom command has mode", async () => {
+			const handler = await getRegisteredHandler()
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+			})
+
+			await handler(mockContext)
+
+			expect(mockContext.setMode).toHaveBeenCalledWith("architect")
+		})
+
+		it("should call updateProviderModel when custom command has model", async () => {
+			const handler = await getRegisteredHandler()
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+			})
+
+			await handler(mockContext)
+
+			expect(mockContext.updateProviderModel).toHaveBeenCalledWith("opus")
+		})
+
+		it("should handle updateProviderModel errors gracefully", async () => {
+			const handler = await getRegisteredHandler()
+			const mockUpdateProviderModel = vi.fn().mockRejectedValue(new Error("Model not available"))
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+				updateProviderModel: mockUpdateProviderModel,
+			})
+
+			// Should not throw
+			await expect(handler(mockContext)).resolves.not.toThrow()
+
+			expect(mockUpdateProviderModel).toHaveBeenCalledWith("opus")
+			// Should still send the message even if model switch fails
+			expect(mockContext.sendWebviewMessage).toHaveBeenCalled()
+		})
+
+		it("should call sendWebviewMessage with processed content", async () => {
+			const handler = await getRegisteredHandler()
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+			})
+
+			await handler(mockContext)
+
+			expect(mockContext.sendWebviewMessage).toHaveBeenCalledWith({
+				type: "newTask",
+				text: "Process input.txt to output.txt with input.txt output.txt",
+			})
+		})
+
+		it("should not call setMode when custom command has no mode", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "no-mode.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Command without mode
+---
+Simple content`)
+
+			await initializeCustomCommands(mockCwd)
+
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			const mockContext = createMockContext({ args: [] })
+
+			await registeredCommand.handler(mockContext)
+
+			expect(mockContext.setMode).not.toHaveBeenCalled()
+		})
+
+		it("should not call updateProviderModel when custom command has no model", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "no-model.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Command without model
+---
+Simple content`)
+
+			await initializeCustomCommands(mockCwd)
+
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			const mockContext = createMockContext({ args: [] })
+
+			await registeredCommand.handler(mockContext)
+
+			expect(mockContext.updateProviderModel).not.toHaveBeenCalled()
+		})
+
+		it("should substitute arguments in content before sending", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "substitute.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Substitution test
+---
+First: $1, Second: $2, All: $ARGUMENTS`)
+
+			await initializeCustomCommands(mockCwd)
+
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			const mockContext = createMockContext({
+				args: ["alpha", "beta", "gamma"],
+			})
+
+			await registeredCommand.handler(mockContext)
+
+			expect(mockContext.sendWebviewMessage).toHaveBeenCalledWith({
+				type: "newTask",
+				text: "First: alpha, Second: beta, All: alpha beta gamma",
+			})
+		})
+	})
+})

+ 189 - 0
cli/src/commands/custom.ts

@@ -0,0 +1,189 @@
+/**
+ * Custom commands - loads markdown-based commands from ~/.kilocode/commands/ and .kilocode/commands/
+ */
+
+import fs from "fs/promises"
+import * as path from "path"
+import matter from "gray-matter"
+import * as os from "os"
+import { commandRegistry } from "./core/registry.js"
+import type { Command, CommandHandler } from "./core/types.js"
+import { logs } from "../services/logs.js"
+
+/**
+ * Custom command definition loaded from markdown files
+ */
+export interface CustomCommand {
+	name: string
+	content: string
+	filePath: string
+	description?: string
+	arguments?: string[]
+	mode?: string
+	model?: string
+}
+
+/**
+ * Validates that a command name contains only alphanumeric characters and hyphens,
+ * and starts with an alphanumeric character
+ */
+function isValidCommandName(name: string): boolean {
+	return /^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(name)
+}
+
+async function scanCommandDirectory(dirPath: string, commands: Map<string, CustomCommand>): Promise<void> {
+	try {
+		const entries = await fs.readdir(dirPath, { withFileTypes: true })
+
+		for (const entry of entries) {
+			if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue
+
+			const commandName = entry.name.slice(0, -3)
+			const filePath = path.join(dirPath, entry.name)
+
+			// Validate command name format.
+			if (!isValidCommandName(commandName)) {
+				logs.warn(
+					`Skipping invalid command name "${commandName}" - must start with alphanumeric and contain only alphanumeric characters and hyphens`,
+					"CustomCommand",
+				)
+				continue
+			}
+
+			try {
+				const content = await fs.readFile(filePath, "utf-8")
+				const parsed = matter(content)
+
+				const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""
+				const mode = typeof parsed.data.mode === "string" ? parsed.data.mode.trim() : ""
+				const model = typeof parsed.data.model === "string" ? parsed.data.model.trim() : ""
+
+				// Parse arguments list
+				let args: string[] | undefined
+				if (Array.isArray(parsed.data.arguments)) {
+					args = parsed.data.arguments
+						.filter((arg) => typeof arg === "string" && arg.trim())
+						.map((arg) => arg.trim())
+				}
+
+				const command: CustomCommand = {
+					name: commandName,
+					content: parsed.content.trim(),
+					filePath,
+				}
+
+				if (description) command.description = description
+				if (args && args.length > 0) command.arguments = args
+				if (mode) command.mode = mode
+				if (model) command.model = model
+
+				commands.set(commandName, command)
+			} catch (error) {
+				logs.warn(`Failed to parse custom command file: ${filePath}`, "CustomCommand", { error })
+			}
+		}
+	} catch (error) {
+		const code = (error as NodeJS.ErrnoException)?.code
+		if (code !== "ENOENT") {
+			logs.warn(`Failed to scan command directory: ${dirPath}`, "CustomCommand", { error })
+		}
+	}
+}
+
+/**
+ * Substitute arguments in command content
+ * Supports: $ARGUMENTS (all args), $1, $2, $3, etc. (positional args)
+ */
+export function substituteArguments(content: string, args: string[]): string {
+	return content.replace(/\$ARGUMENTS\b/g, args.join(" ")).replace(/\$(\d+)\b(?!\.?\d)/g, (match, num): string => {
+		const index = parseInt(num, 10) - 1
+		return index >= 0 && index < args.length ? args[index]! : match
+	})
+}
+
+function createCustomCommandHandler(customCommand: CustomCommand): CommandHandler {
+	return async (context) => {
+		const { args, setMode, updateProviderModel, sendWebviewMessage } = context
+
+		if (customCommand.mode) {
+			setMode(customCommand.mode)
+		}
+
+		if (customCommand.model) {
+			try {
+				await updateProviderModel(customCommand.model)
+			} catch (error) {
+				logs.warn(`Failed to switch to model ${customCommand.model}`, "CustomCommand", { error })
+			}
+		}
+
+		const processedContent = substituteArguments(customCommand.content, args)
+
+		await sendWebviewMessage({
+			type: "newTask",
+			text: processedContent,
+		})
+	}
+}
+
+function customCommandToCliCommand(customCommand: CustomCommand): Command {
+	return {
+		name: customCommand.name,
+		aliases: [],
+		description: customCommand.description || `Custom command: ${customCommand.name}`,
+		usage: customCommand.arguments
+			? `/${customCommand.name} ${customCommand.arguments.map((arg) => `<${arg}>`).join(" ")}`
+			: `/${customCommand.name}`,
+		examples: [`/${customCommand.name}`],
+		category: "chat",
+		handler: createCustomCommandHandler(customCommand),
+		priority: 3,
+		...(customCommand.arguments && {
+			arguments: customCommand.arguments.map((argument) => ({
+				name: argument,
+				description: "",
+				required: false,
+			})),
+		}),
+	}
+}
+
+/**
+ * Load custom commands from ~/.kilocode/commands/ and .kilocode/commands/
+ * Priority: project > global
+ */
+export async function getCustomCommands(cwd: string): Promise<CustomCommand[]> {
+	const commands = new Map<string, CustomCommand>()
+
+	const globalDir = path.join(os.homedir(), ".kilocode", "commands")
+	await scanCommandDirectory(globalDir, commands)
+
+	const projectDir = path.join(cwd, ".kilocode", "commands")
+	await scanCommandDirectory(projectDir, commands)
+
+	return Array.from(commands.values())
+}
+
+/**
+ * Initialize custom commands from markdown files
+ * Call this after built-in commands are initialized
+ */
+export async function initializeCustomCommands(cwd: string): Promise<void> {
+	try {
+		const customCommands = await getCustomCommands(cwd)
+
+		for (const customCommand of customCommands) {
+			if (commandRegistry.get(customCommand.name)) {
+				logs.warn(`Custom command "${customCommand.name}" conflicts with an existing command`, "CustomCommand")
+				continue
+			}
+			commandRegistry.register(customCommandToCliCommand(customCommand))
+		}
+
+		if (customCommands.length > 0) {
+			logs.debug(`Loaded ${customCommands.length} custom command(s)`, "CustomCommand")
+		}
+	} catch (error) {
+		logs.warn("Failed to load custom commands", "CustomCommand", { error })
+	}
+}

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

@@ -24,7 +24,7 @@ import { sessionCommand } from "./session.js"
 import { condenseCommand } from "./condense.js"
 
 /**
- * Initialize all commands
+ * Initialize all built-in commands
  */
 export function initializeCommands(): void {
 	// Register all commands

+ 2 - 109
cli/src/commands/models-api.ts

@@ -39,10 +39,9 @@ import {
 	type ModelInfo,
 	type RouterModels,
 } from "../constants/providers/models.js"
-import type { ProviderName, ExtensionMessage } from "../types/messages.js"
+import type { ProviderName } from "../types/messages.js"
 import type { CLIConfig, ProviderConfig } from "../config/types.js"
-import { createExtensionService, type ExtensionService } from "../services/extension.js"
-import { mapProviderToApiConfig } from "../config/mapper.js"
+import { fetchRouterModels } from "../services/models/fetcher.js"
 
 /**
  * Output format for the models API command
@@ -129,9 +128,6 @@ export function transformModelsToOutput(
 	}
 }
 
-/** Default timeout for router models request (30 seconds) */
-const ROUTER_MODELS_TIMEOUT_MS = 30000
-
 /**
  * Output result as JSON to stdout
  */
@@ -147,109 +143,6 @@ function outputError(message: string, code: string): never {
 	process.exit(1)
 }
 
-/**
- * Fetch router models from the extension
- *
- * This function:
- * 1. Creates an ExtensionService
- * 2. Initializes it (loads and activates the extension)
- * 3. Injects provider configuration
- * 4. Sends requestRouterModels message
- * 5. Waits for routerModels response with timeout
- * 6. Disposes the service
- *
- * @param provider - The provider configuration
- * @param timeoutMs - Timeout in milliseconds (default: 30000)
- * @returns RouterModels or null if fetch failed
- */
-async function fetchRouterModels(
-	provider: ProviderConfig,
-	timeoutMs: number = ROUTER_MODELS_TIMEOUT_MS,
-): Promise<RouterModels | null> {
-	let service: ExtensionService | null = null
-
-	try {
-		logs.info("Initializing extension to fetch router models", "ModelsAPI")
-
-		// Create extension service
-		service = createExtensionService({
-			workspace: process.cwd(),
-		})
-
-		// Initialize the service (loads and activates extension)
-		await service.initialize()
-		logs.debug("Extension service initialized", "ModelsAPI")
-
-		// Wait for the service to be ready
-		if (!service.isReady()) {
-			// Wait for the 'ready' event with timeout
-			await new Promise<void>((resolve, reject) => {
-				const timeout = setTimeout(() => {
-					reject(new Error("Extension service ready timeout"))
-				}, 10000)
-
-				service!.once("ready", () => {
-					clearTimeout(timeout)
-					resolve()
-				})
-			})
-		}
-
-		// Inject provider configuration
-		const apiConfiguration = mapProviderToApiConfig(provider)
-		const extensionHost = service.getExtensionHost()
-		await extensionHost.injectConfiguration({
-			apiConfiguration,
-			currentApiConfigName: provider.id,
-		})
-		logs.debug("Provider configuration injected", "ModelsAPI")
-
-		// Create a promise that resolves when we receive routerModels
-		const routerModelsPromise = new Promise<RouterModels | null>((resolve, reject) => {
-			const timeout = setTimeout(() => {
-				reject(new Error(`Router models request timed out after ${timeoutMs}ms`))
-			}, timeoutMs)
-
-			const messageHandler = (message: ExtensionMessage) => {
-				if (message.type === "routerModels" && message.routerModels) {
-					clearTimeout(timeout)
-					service!.off("message", messageHandler)
-					resolve(message.routerModels as RouterModels)
-				}
-			}
-
-			service!.on("message", messageHandler)
-		})
-
-		// Send requestRouterModels message
-		await service.sendWebviewMessage({
-			type: "requestRouterModels",
-		})
-		logs.debug("Sent requestRouterModels message", "ModelsAPI")
-
-		// Wait for response
-		const routerModels = await routerModelsPromise
-		logs.info("Received router models", "ModelsAPI", {
-			providerCount: routerModels ? Object.keys(routerModels).length : 0,
-		})
-
-		return routerModels
-	} catch (error) {
-		logs.error("Failed to fetch router models", "ModelsAPI", { error })
-		return null
-	} finally {
-		// Always dispose the service
-		if (service) {
-			try {
-				await service.dispose()
-				logs.debug("Extension service disposed", "ModelsAPI")
-			} catch (disposeError) {
-				logs.warn("Error disposing extension service", "ModelsAPI", { error: disposeError })
-			}
-		}
-	}
-}
-
 /**
  * Main models API command handler
  *

+ 58 - 0
cli/src/config/__tests__/persistence.test.ts

@@ -190,6 +190,64 @@ describe("Config Persistence", () => {
 			expect(result.validation.errors).toBeDefined()
 			expect(result.validation.errors!.length).toBeGreaterThan(0)
 		})
+
+		it("should return default config when config file is empty", async () => {
+			// Create an empty config file
+			await ensureConfigDir()
+			await fs.writeFile(TEST_CONFIG_FILE, "")
+
+			// Verify file exists
+			const exists = await configExists()
+			expect(exists).toBe(true)
+
+			// Load config - should return default config instead of throwing
+			const result = await loadConfig()
+			expect(result.config).toEqual(DEFAULT_CONFIG)
+			// Default config has empty credentials, so validation should fail
+			expect(result.validation.valid).toBe(false)
+		})
+
+		it("should return default config when config file contains only whitespace", async () => {
+			// Create a config file with only whitespace
+			await ensureConfigDir()
+			await fs.writeFile(TEST_CONFIG_FILE, "   \n\t  \n  ")
+
+			// Verify file exists
+			const exists = await configExists()
+			expect(exists).toBe(true)
+
+			// Load config - should return default config instead of throwing
+			const result = await loadConfig()
+			expect(result.config).toEqual(DEFAULT_CONFIG)
+		})
+
+		it("should return default config when config file contains invalid JSON", async () => {
+			// Create a config file with invalid JSON
+			await ensureConfigDir()
+			await fs.writeFile(TEST_CONFIG_FILE, '{ "version": "1.0.0", invalid json }')
+
+			// Verify file exists
+			const exists = await configExists()
+			expect(exists).toBe(true)
+
+			// Load config - should return default config instead of throwing
+			const result = await loadConfig()
+			expect(result.config).toEqual(DEFAULT_CONFIG)
+		})
+
+		it("should return default config when config file is truncated JSON", async () => {
+			// Create a config file with truncated JSON (simulating interrupted write)
+			await ensureConfigDir()
+			await fs.writeFile(TEST_CONFIG_FILE, '{ "version": "1.0.0", "mode": "code"')
+
+			// Verify file exists
+			const exists = await configExists()
+			expect(exists).toBe(true)
+
+			// Load config - should return default config instead of throwing
+			const result = await loadConfig()
+			expect(result.config).toEqual(DEFAULT_CONFIG)
+		})
 	})
 
 	describe("saveConfig", () => {

+ 25 - 1
cli/src/config/persistence.ts

@@ -170,7 +170,31 @@ export async function loadConfig(): Promise<ConfigLoadResult> {
 
 		// Read and parse config
 		const content = await fs.readFile(configFile, "utf-8")
-		const loadedConfig = JSON.parse(content)
+
+		// Handle empty or whitespace-only config files
+		if (!content || content.trim().length === 0) {
+			logs.warn("Config file is empty, returning default config", "ConfigPersistence")
+			const validation = await validateConfig(DEFAULT_CONFIG)
+			return {
+				config: DEFAULT_CONFIG,
+				validation,
+			}
+		}
+
+		// Parse JSON with error handling for corrupted files
+		let loadedConfig: Partial<CLIConfig>
+		try {
+			loadedConfig = JSON.parse(content) as Partial<CLIConfig>
+		} catch (parseError) {
+			logs.error("Config file contains invalid JSON, returning default config", "ConfigPersistence", {
+				error: parseError,
+			})
+			const validation = await validateConfig(DEFAULT_CONFIG)
+			return {
+				config: DEFAULT_CONFIG,
+				validation,
+			}
+		}
 
 		// Merge with defaults to fill in missing keys
 		const config = mergeWithDefaults(loadedConfig)

+ 29 - 3
cli/src/index.ts

@@ -101,8 +101,10 @@ program
 		}
 
 		// Read from stdin if no prompt argument is provided and stdin is piped
+		// BUT NOT in json-io mode, where stdin is used for bidirectional communication
+		// and the prompt will come via a "newTask" message
 		let finalPrompt = prompt || ""
-		if (!finalPrompt && !process.stdin.isTTY) {
+		if (!finalPrompt && !process.stdin.isTTY && !options.jsonIo) {
 			// Read from stdin
 			const chunks: Buffer[] = []
 			for await (const chunk of process.stdin) {
@@ -188,7 +190,11 @@ program
 
 		// Validate attachments if specified
 		const attachments: string[] = options.attach || []
-		const attachRequiresAutoResult = validateAttachRequiresAuto({ attach: attachments, auto: options.auto })
+		const attachRequiresAutoResult = validateAttachRequiresAuto({
+			attach: attachments,
+			auto: options.auto,
+			jsonIo: options.jsonIo,
+		})
 		if (!attachRequiresAutoResult.valid) {
 			console.error(attachRequiresAutoResult.error)
 			process.exit(1)
@@ -216,7 +222,27 @@ program
 		const hasEnvConfig = envConfigExists()
 
 		if (!hasConfig && !hasEnvConfig) {
-			// No config file and no env config - show auth wizard
+			// No config file and no env config
+			// Check if running in agent-manager mode (spawned from VS Code extension)
+			if (process.env.KILO_PLATFORM === "agent-manager") {
+				// Output a welcome message with instructions that the agent manager can detect.
+				// The agent manager will show a localized error dialog with "Run kilocode auth"
+				// and "Run kilocode config" buttons. The instructions here are just for
+				// triggering the cli_configuration_error handler and providing log context.
+				const welcomeMessage = {
+					type: "welcome",
+					timestamp: Date.now(),
+					metadata: {
+						welcomeOptions: {
+							instructions: ["Configuration required: No provider configured."],
+						},
+					},
+				}
+				console.log(JSON.stringify(welcomeMessage))
+				process.exit(1)
+			}
+
+			// Interactive mode - show auth wizard
 			console.info("Welcome to the Kilo Code CLI! 🎉\n")
 			console.info("To get you started, please fill out these following questions.")
 			await authWizard()

+ 85 - 0
cli/src/media/__tests__/image-utils.test.ts

@@ -0,0 +1,85 @@
+/**
+ * Tests for image-utils module
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { isDataUrl, convertImagesToDataUrls } from "../image-utils.js"
+
+// Mock the processImagePaths function
+vi.mock("../images.js", () => ({
+	processImagePaths: vi.fn().mockImplementation(async (paths: string[]) => ({
+		images: paths.map((p) => `data:image/png;base64,mock-${p.replace(/[^a-zA-Z0-9]/g, "")}`),
+		errors: [],
+	})),
+}))
+
+// Mock the logs service
+vi.mock("../../services/logs.js", () => ({
+	logs: {
+		error: vi.fn(),
+		debug: vi.fn(),
+		warn: vi.fn(),
+	},
+}))
+
+describe("isDataUrl", () => {
+	it("should return true for valid data URLs", () => {
+		expect(isDataUrl("data:image/png;base64,abc123")).toBe(true)
+		expect(isDataUrl("data:image/jpeg;base64,xyz")).toBe(true)
+		expect(isDataUrl("data:text/plain;base64,hello")).toBe(true)
+	})
+
+	it("should return false for file paths", () => {
+		expect(isDataUrl("/tmp/image.png")).toBe(false)
+		expect(isDataUrl("./screenshot.png")).toBe(false)
+		expect(isDataUrl("C:\\Users\\image.png")).toBe(false)
+	})
+
+	it("should return false for URLs that are not data URLs", () => {
+		expect(isDataUrl("https://example.com/image.png")).toBe(false)
+		expect(isDataUrl("file:///tmp/image.png")).toBe(false)
+	})
+})
+
+describe("convertImagesToDataUrls", () => {
+	beforeEach(() => {
+		vi.clearAllMocks()
+	})
+
+	it("should return empty result for undefined input", async () => {
+		const result = await convertImagesToDataUrls(undefined)
+		expect(result).toEqual({ images: [], errors: [] })
+	})
+
+	it("should return empty result for empty array", async () => {
+		const result = await convertImagesToDataUrls([])
+		expect(result).toEqual({ images: [], errors: [] })
+	})
+
+	it("should pass through data URLs unchanged", async () => {
+		const dataUrl =
+			"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+		const result = await convertImagesToDataUrls([dataUrl])
+		expect(result.images).toEqual([dataUrl])
+		expect(result.errors).toEqual([])
+	})
+
+	it("should convert file paths to data URLs", async () => {
+		const result = await convertImagesToDataUrls(["/tmp/image.png"])
+		expect(result.images).toEqual(["data:image/png;base64,mock-tmpimagepng"])
+		expect(result.errors).toEqual([])
+	})
+
+	it("should handle mixed data URLs and file paths", async () => {
+		const dataUrl = "data:image/png;base64,existing"
+		const result = await convertImagesToDataUrls([dataUrl, "/tmp/new.png"])
+		expect(result.images).toEqual([dataUrl, "data:image/png;base64,mock-tmpnewpng"])
+		expect(result.errors).toEqual([])
+	})
+
+	it("should handle multiple file paths", async () => {
+		const result = await convertImagesToDataUrls(["/tmp/a.png", "/tmp/b.png"])
+		expect(result.images).toEqual(["data:image/png;base64,mock-tmpapng", "data:image/png;base64,mock-tmpbpng"])
+		expect(result.errors).toEqual([])
+	})
+})

+ 72 - 0
cli/src/media/image-utils.ts

@@ -0,0 +1,72 @@
+/**
+ * Utility functions for image handling in the CLI.
+ * Provides conversion between file paths and data URLs.
+ */
+
+import { processImagePaths } from "./images.js"
+import { logs } from "../services/logs.js"
+
+/**
+ * Check if a string is a data URL (starts with "data:")
+ */
+export function isDataUrl(str: string): boolean {
+	return str.startsWith("data:")
+}
+
+/**
+ * Result of image conversion, including both successful conversions and errors.
+ */
+export interface ImageConversionResult {
+	/** Successfully converted data URLs */
+	images: string[]
+	/** Errors for images that failed to convert */
+	errors: Array<{ path: string; error: string }>
+}
+
+/**
+ * Convert image paths to data URLs if needed.
+ * If images are already data URLs, they are passed through unchanged.
+ * If images are file paths, they are read and converted to data URLs.
+ *
+ * @param images Array of image paths or data URLs
+ * @param logContext Optional context string for error logging
+ * @returns Object containing successful images and any errors
+ */
+export async function convertImagesToDataUrls(
+	images: string[] | undefined,
+	logContext: string = "image-utils",
+): Promise<ImageConversionResult> {
+	if (!images || images.length === 0) {
+		return { images: [], errors: [] }
+	}
+
+	// Separate data URLs from file paths
+	const dataUrls: string[] = []
+	const filePaths: string[] = []
+
+	for (const image of images) {
+		if (isDataUrl(image)) {
+			dataUrls.push(image)
+		} else {
+			filePaths.push(image)
+		}
+	}
+
+	// If all images are already data URLs, return them directly
+	if (filePaths.length === 0) {
+		return { images: dataUrls, errors: [] }
+	}
+
+	// Convert file paths to data URLs
+	const result = await processImagePaths(filePaths)
+
+	if (result.errors.length > 0) {
+		for (const error of result.errors) {
+			logs.error(`Failed to load image "${error.path}": ${error.error}`, logContext)
+		}
+	}
+
+	// Combine existing data URLs with newly converted ones
+	const allDataUrls = [...dataUrls, ...result.images]
+	return { images: allDataUrls, errors: result.errors }
+}

+ 111 - 0
cli/src/services/models/fetcher.ts

@@ -0,0 +1,111 @@
+import { logs } from "../../services/logs.js"
+import { createExtensionService, type ExtensionService } from "../../services/extension.js"
+import { mapProviderToApiConfig } from "../../config/mapper.js"
+import type { ProviderConfig } from "../../config/types.js"
+import type { RouterModels, ExtensionMessage } from "../../types/messages.js"
+
+/** Default timeout for router models request (30 seconds) */
+const ROUTER_MODELS_TIMEOUT_MS = 30000
+
+/**
+ * Fetch router models from the extension
+ *
+ * This function:
+ * 1. Creates an ExtensionService
+ * 2. Initializes it (loads and activates the extension)
+ * 3. Injects provider configuration
+ * 4. Sends requestRouterModels message
+ * 5. Waits for routerModels response with timeout
+ * 6. Disposes the service
+ *
+ * @param provider - The provider configuration
+ * @param timeoutMs - Timeout in milliseconds (default: 30000)
+ * @returns RouterModels or null if fetch failed
+ */
+export async function fetchRouterModels(
+	provider: ProviderConfig,
+	timeoutMs: number = ROUTER_MODELS_TIMEOUT_MS,
+): Promise<RouterModels | null> {
+	let service: ExtensionService | null = null
+
+	try {
+		logs.info("Initializing extension to fetch router models", "ModelFetcher")
+
+		// Create extension service
+		service = createExtensionService({
+			workspace: process.cwd(),
+		})
+
+		// Initialize the service (loads and activates extension)
+		await service.initialize()
+		logs.debug("Extension service initialized", "ModelFetcher")
+
+		// Wait for the service to be ready
+		if (!service.isReady()) {
+			// Wait for the 'ready' event with timeout
+			await new Promise<void>((resolve, reject) => {
+				const timeout = setTimeout(() => {
+					reject(new Error("Extension service ready timeout"))
+				}, 10000)
+
+				service!.once("ready", () => {
+					clearTimeout(timeout)
+					resolve()
+				})
+			})
+		}
+
+		// Inject provider configuration
+		const apiConfiguration = mapProviderToApiConfig(provider)
+		const extensionHost = service.getExtensionHost()
+		await extensionHost.injectConfiguration({
+			apiConfiguration,
+			currentApiConfigName: provider.id,
+		})
+		logs.debug("Provider configuration injected", "ModelFetcher")
+
+		// Create a promise that resolves when we receive routerModels
+		const routerModelsPromise = new Promise<RouterModels | null>((resolve, reject) => {
+			const timeout = setTimeout(() => {
+				reject(new Error(`Router models request timed out after ${timeoutMs}ms`))
+			}, timeoutMs)
+
+			const messageHandler = (message: ExtensionMessage) => {
+				if (message.type === "routerModels" && message.routerModels) {
+					clearTimeout(timeout)
+					service!.off("message", messageHandler)
+					resolve(message.routerModels as RouterModels)
+				}
+			}
+
+			service!.on("message", messageHandler)
+		})
+
+		// Send requestRouterModels message
+		await service.sendWebviewMessage({
+			type: "requestRouterModels",
+		})
+		logs.debug("Sent requestRouterModels message", "ModelFetcher")
+
+		// Wait for response
+		const routerModels = await routerModelsPromise
+		logs.info("Received router models", "ModelFetcher", {
+			providerCount: routerModels ? Object.keys(routerModels).length : 0,
+		})
+
+		return routerModels
+	} catch (error) {
+		logs.error("Failed to fetch router models", "ModelFetcher", { error })
+		return null
+	} finally {
+		// Always dispose the service
+		if (service) {
+			try {
+				await service.dispose()
+				logs.debug("Extension service disposed", "ModelFetcher")
+			} catch (disposeError) {
+				logs.warn("Error disposing extension service", "ModelFetcher", { error: disposeError })
+			}
+		}
+	}
+}

+ 71 - 0
cli/src/state/atoms/__tests__/effects-ci-completion.test.ts

@@ -0,0 +1,71 @@
+/**
+ * Tests for CI completion detection in effects.ts
+ */
+
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import { createStore } from "jotai"
+import { messageHandlerEffectAtom } from "../effects.js"
+import { extensionServiceAtom } from "../service.js"
+import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "../ci.js"
+import { taskResumedViaContinueOrSessionAtom } from "../extension.js"
+import type { ExtensionMessage, ExtensionChatMessage } from "../../../types/messages.js"
+import type { ExtensionService } from "../../../services/extension.js"
+
+describe("CI completion detection in effects", () => {
+	let store: ReturnType<typeof createStore>
+
+	beforeEach(() => {
+		store = createStore()
+
+		const mockService: Partial<ExtensionService> = {
+			initialize: vi.fn(),
+			dispose: vi.fn(),
+			on: vi.fn(),
+			off: vi.fn(),
+		}
+		store.set(extensionServiceAtom, mockService as ExtensionService)
+	})
+
+	it("skips completion detection when session was resumed", () => {
+		const completionMessage: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "completion_result",
+			text: "Task completed",
+		}
+
+		const stateMessage: ExtensionMessage = {
+			type: "state",
+			state: {
+				chatMessages: [completionMessage],
+			} as ExtensionMessage["state"],
+		}
+
+		store.set(taskResumedViaContinueOrSessionAtom, true)
+		store.set(messageHandlerEffectAtom, stateMessage)
+
+		expect(store.get(ciCompletionDetectedAtom)).toBe(false)
+	})
+
+	it("uses the ignore timestamp to skip historical completion_result", () => {
+		const historicalTs = Date.now()
+		const completionMessage: ExtensionChatMessage = {
+			ts: historicalTs,
+			type: "ask",
+			ask: "completion_result",
+			text: "Task completed",
+		}
+
+		const stateMessage: ExtensionMessage = {
+			type: "state",
+			state: {
+				chatMessages: [completionMessage],
+			} as ExtensionMessage["state"],
+		}
+
+		store.set(ciCompletionIgnoreBeforeTimestampAtom, historicalTs)
+		store.set(messageHandlerEffectAtom, stateMessage)
+
+		expect(store.get(ciCompletionDetectedAtom)).toBe(false)
+	})
+})

+ 31 - 14
cli/src/state/atoms/__tests__/keyboard.test.ts

@@ -1394,7 +1394,7 @@ describe("keypress atoms", () => {
 			expect(text).toBe(smallPaste)
 		})
 
-		it("should abbreviate large pastes as references", () => {
+		it("should abbreviate large pastes as references", async () => {
 			// Large paste (10+ lines to trigger abbreviation)
 			const lines = Array.from({ length: 15 }, (_, i) => `line ${i + 1}`)
 			const largePaste = lines.join("\n")
@@ -1409,13 +1409,18 @@ describe("keypress atoms", () => {
 
 			store.set(keyboardHandlerAtom, pasteKey)
 
+			// Wait for async paste operation to complete
+			await vi.waitFor(() => {
+				const text = store.get(textBufferStringAtom)
+				expect(text).toContain("[Pasted text #1 +15 lines]")
+			})
+
 			// Should insert abbreviated reference
 			const text = store.get(textBufferStringAtom)
-			expect(text).toContain("[Pasted text #1 +15 lines]")
 			expect(text).not.toContain("line 1")
 		})
 
-		it("should store full text in references map for large pastes", () => {
+		it("should store full text in references map for large pastes", async () => {
 			const lines = Array.from({ length: 12 }, (_, i) => `content line ${i + 1}`)
 			const largePaste = lines.join("\n")
 			const pasteKey: Key = {
@@ -1429,12 +1434,14 @@ describe("keypress atoms", () => {
 
 			store.set(keyboardHandlerAtom, pasteKey)
 
-			// Full text should be in references map
-			const refs = store.get(pastedTextReferencesAtom)
-			expect(refs.get(1)).toBe(largePaste)
+			// Wait for async paste operation to complete
+			await vi.waitFor(() => {
+				const refs = store.get(pastedTextReferencesAtom)
+				expect(refs.get(1)).toBe(largePaste)
+			})
 		})
 
-		it("should increment reference numbers for multiple large pastes", () => {
+		it("should increment reference numbers for multiple large pastes", async () => {
 			const createLargePaste = (id: number) => {
 				const lines = Array.from({ length: 11 }, (_, i) => `paste${id} line ${i + 1}`)
 				return lines.join("\n")
@@ -1450,6 +1457,12 @@ describe("keypress atoms", () => {
 				paste: true,
 			})
 
+			// Wait for first paste to complete
+			await vi.waitFor(() => {
+				const text = store.get(textBufferStringAtom)
+				expect(text).toContain("[Pasted text #1 +11 lines]")
+			})
+
 			// Add a space
 			store.set(keyboardHandlerAtom, {
 				name: "space",
@@ -1470,12 +1483,14 @@ describe("keypress atoms", () => {
 				paste: true,
 			})
 
-			const text = store.get(textBufferStringAtom)
-			expect(text).toContain("[Pasted text #1 +11 lines]")
-			expect(text).toContain("[Pasted text #2 +11 lines]")
+			// Wait for second paste to complete
+			await vi.waitFor(() => {
+				const text = store.get(textBufferStringAtom)
+				expect(text).toContain("[Pasted text #2 +11 lines]")
+			})
 		})
 
-		it("should handle paste at exactly threshold boundary", () => {
+		it("should handle paste at exactly threshold boundary", async () => {
 			// Exactly 10 lines (threshold)
 			const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`)
 			const boundaryPaste = lines.join("\n")
@@ -1490,9 +1505,11 @@ describe("keypress atoms", () => {
 
 			store.set(keyboardHandlerAtom, pasteKey)
 
-			// Should abbreviate (>= threshold)
-			const text = store.get(textBufferStringAtom)
-			expect(text).toContain("[Pasted text #1 +10 lines]")
+			// Wait for async paste operation to complete
+			await vi.waitFor(() => {
+				const text = store.get(textBufferStringAtom)
+				expect(text).toContain("[Pasted text #1 +10 lines]")
+			})
 		})
 
 		it("should not abbreviate paste just below threshold", () => {

+ 227 - 0
cli/src/state/atoms/__tests__/taskHistory.test.ts

@@ -0,0 +1,227 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
+import { createStore } from "jotai"
+import {
+	taskHistoryPendingRequestsAtom,
+	addPendingRequestAtom,
+	removePendingRequestAtom,
+	resolveTaskHistoryRequestAtom,
+	type TaskHistoryData,
+} from "../taskHistory.js"
+import type { HistoryItem } from "@roo-code/types"
+
+/**
+ * Creates a minimal mock HistoryItem for testing
+ */
+function createMockHistoryItem(overrides: Partial<HistoryItem> = {}): HistoryItem {
+	return {
+		id: "task-1",
+		number: 1,
+		ts: Date.now(),
+		task: "Test task",
+		tokensIn: 100,
+		tokensOut: 200,
+		totalCost: 0.01,
+		...overrides,
+	}
+}
+
+describe("taskHistory atoms", () => {
+	let store: ReturnType<typeof createStore>
+
+	beforeEach(() => {
+		store = createStore()
+		vi.useFakeTimers()
+	})
+
+	afterEach(() => {
+		vi.useRealTimers()
+	})
+
+	describe("addPendingRequestAtom", () => {
+		it("should add a pending request to the map", () => {
+			const resolve = vi.fn()
+			const reject = vi.fn()
+			const timeout = setTimeout(() => {}, 5000)
+
+			store.set(addPendingRequestAtom, {
+				requestId: "test-123",
+				resolve,
+				reject,
+				timeout,
+			})
+
+			const pendingRequests = store.get(taskHistoryPendingRequestsAtom)
+			expect(pendingRequests.size).toBe(1)
+			expect(pendingRequests.has("test-123")).toBe(true)
+
+			clearTimeout(timeout)
+		})
+	})
+
+	describe("removePendingRequestAtom", () => {
+		it("should remove a pending request and clear its timeout", () => {
+			const resolve = vi.fn()
+			const reject = vi.fn()
+			const timeoutCallback = vi.fn()
+			const timeout = setTimeout(timeoutCallback, 5000)
+
+			// Add the request first
+			store.set(addPendingRequestAtom, {
+				requestId: "test-456",
+				resolve,
+				reject,
+				timeout,
+			})
+
+			// Remove it
+			store.set(removePendingRequestAtom, "test-456")
+
+			const pendingRequests = store.get(taskHistoryPendingRequestsAtom)
+			expect(pendingRequests.size).toBe(0)
+
+			// Verify timeout was cleared
+			vi.advanceTimersByTime(6000)
+			expect(timeoutCallback).not.toHaveBeenCalled()
+		})
+
+		it("should do nothing if request ID does not exist", () => {
+			store.set(removePendingRequestAtom, "nonexistent")
+			const pendingRequests = store.get(taskHistoryPendingRequestsAtom)
+			expect(pendingRequests.size).toBe(0)
+		})
+	})
+
+	describe("resolveTaskHistoryRequestAtom", () => {
+		it("should resolve a pending request with data", () => {
+			const resolve = vi.fn()
+			const reject = vi.fn()
+			const timeout = setTimeout(() => {}, 5000)
+
+			// Add the request
+			store.set(addPendingRequestAtom, {
+				requestId: "test-789",
+				resolve,
+				reject,
+				timeout,
+			})
+
+			const mockData: TaskHistoryData = {
+				historyItems: [createMockHistoryItem()],
+				pageIndex: 0,
+				pageCount: 1,
+			}
+
+			// Resolve it
+			store.set(resolveTaskHistoryRequestAtom, {
+				requestId: "test-789",
+				data: mockData,
+			})
+
+			// Verify resolve was called with data
+			expect(resolve).toHaveBeenCalledWith(mockData)
+			expect(reject).not.toHaveBeenCalled()
+
+			// Verify request was removed
+			const pendingRequests = store.get(taskHistoryPendingRequestsAtom)
+			expect(pendingRequests.size).toBe(0)
+		})
+
+		it("should reject a pending request with error", () => {
+			const resolve = vi.fn()
+			const reject = vi.fn()
+			const timeout = setTimeout(() => {}, 5000)
+
+			// Add the request
+			store.set(addPendingRequestAtom, {
+				requestId: "test-error",
+				resolve,
+				reject,
+				timeout,
+			})
+
+			// Resolve with error
+			store.set(resolveTaskHistoryRequestAtom, {
+				requestId: "test-error",
+				error: "Something went wrong",
+			})
+
+			// Verify reject was called
+			expect(reject).toHaveBeenCalledWith(expect.any(Error))
+			expect(reject.mock.calls[0][0].message).toBe("Something went wrong")
+			expect(resolve).not.toHaveBeenCalled()
+
+			// Verify request was removed
+			const pendingRequests = store.get(taskHistoryPendingRequestsAtom)
+			expect(pendingRequests.size).toBe(0)
+		})
+
+		it("should do nothing if request ID does not exist", () => {
+			const mockData: TaskHistoryData = {
+				historyItems: [],
+				pageIndex: 0,
+				pageCount: 0,
+			}
+
+			// Should not throw
+			store.set(resolveTaskHistoryRequestAtom, {
+				requestId: "nonexistent",
+				data: mockData,
+			})
+		})
+	})
+
+	describe("Promise-based task history flow", () => {
+		it("should resolve promise when response arrives before timeout", async () => {
+			const TIMEOUT_MS = 5000
+			const requestId = "flow-test-1"
+
+			// Simulate the flow used in CLI.resumeConversation
+			const resultPromise = new Promise<TaskHistoryData>((resolve, reject) => {
+				const timeout = setTimeout(() => {
+					store.set(removePendingRequestAtom, requestId)
+					reject(new Error(`Request timed out after ${TIMEOUT_MS}ms`))
+				}, TIMEOUT_MS)
+
+				store.set(addPendingRequestAtom, { requestId, resolve, reject, timeout })
+			})
+
+			// Simulate response arriving
+			const mockData: TaskHistoryData = {
+				historyItems: [createMockHistoryItem()],
+				pageIndex: 0,
+				pageCount: 1,
+			}
+
+			store.set(resolveTaskHistoryRequestAtom, { requestId, data: mockData })
+
+			// Should resolve with data
+			const result = await resultPromise
+			expect(result).toEqual(mockData)
+		})
+
+		it("should reject promise when timeout occurs", async () => {
+			const TIMEOUT_MS = 5000
+			const requestId = "flow-test-2"
+
+			// Simulate the flow used in CLI.resumeConversation
+			const resultPromise = new Promise<TaskHistoryData>((resolve, reject) => {
+				const timeout = setTimeout(() => {
+					store.set(removePendingRequestAtom, requestId)
+					reject(new Error(`Request timed out after ${TIMEOUT_MS}ms`))
+				}, TIMEOUT_MS)
+
+				store.set(addPendingRequestAtom, { requestId, resolve, reject, timeout })
+			})
+
+			// Advance time past timeout
+			vi.advanceTimersByTime(TIMEOUT_MS + 100)
+
+			// Should reject with timeout error
+			await expect(resultPromise).rejects.toThrow(`Request timed out after ${TIMEOUT_MS}ms`)
+
+			// Verify request was removed
+			const pendingRequests = store.get(taskHistoryPendingRequestsAtom)
+			expect(pendingRequests.size).toBe(0)
+		})
+	})
+})

+ 6 - 0
cli/src/state/atoms/ci.ts

@@ -20,6 +20,11 @@ export const ciTimeoutAtom = atom<number | undefined>(undefined)
  */
 export const ciCompletionDetectedAtom = atom<boolean>(false)
 
+/**
+ * Ignore completion_result messages at or before this timestamp
+ */
+export const ciCompletionIgnoreBeforeTimestampAtom = atom<number>(0)
+
 /**
  * Atom to track if command/message execution has finished
  */
@@ -45,6 +50,7 @@ export const resetCIStateAtom = atom(null, (get, set) => {
 	set(ciModeAtom, false)
 	set(ciTimeoutAtom, undefined)
 	set(ciCompletionDetectedAtom, false)
+	set(ciCompletionIgnoreBeforeTimestampAtom, 0)
 	set(ciCommandFinishedAtom, false)
 	set(ciExitReasonAtom, null)
 })

+ 16 - 4
cli/src/state/atoms/effects.ts

@@ -13,8 +13,9 @@ import {
 	updateRouterModelsAtom,
 	chatMessagesAtom,
 	updateChatMessagesAtom,
+	taskResumedViaContinueOrSessionAtom,
 } from "./extension.js"
-import { ciCompletionDetectedAtom } from "./ci.js"
+import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "./ci.js"
 import {
 	updateProfileDataAtom,
 	updateBalanceDataAtom,
@@ -611,11 +612,22 @@ export const messageHandlerEffectAtom = atom(null, (get, set, message: Extension
 		if (message.state?.chatMessages) {
 			const lastMessage = message.state.chatMessages[message.state.chatMessages.length - 1]
 			if (lastMessage?.type === "ask" && lastMessage?.ask === "completion_result") {
-				logs.info("Completion result detected in state update", "effects")
+				const completionIgnoreBeforeTimestamp = get(ciCompletionIgnoreBeforeTimestampAtom)
+				const taskResumedViaSession = get(taskResumedViaContinueOrSessionAtom)
+				const isHistoricalCompletion =
+					lastMessage.ts !== undefined && lastMessage.ts <= completionIgnoreBeforeTimestamp
+
+				// Skip completion detection if session was just restored via --session or --continue
+				// The historical completion_result from the previous task should not trigger CI exit
+				if (taskResumedViaSession || isHistoricalCompletion) {
+					logs.debug("Skipping completion_result detection - historical completion_result", "effects")
+				} else {
+					logs.info("Completion result detected in state update", "effects")
 
-				set(ciCompletionDetectedAtom, true)
+					set(ciCompletionDetectedAtom, true)
 
-				SessionManager.init()?.doSync(true)
+					SessionManager.init()?.doSync(true)
+				}
 			}
 		}
 	} catch (error) {

+ 62 - 14
cli/src/state/atoms/keyboard.ts

@@ -124,6 +124,20 @@ export const getImageReferencesAtom = atom((get) => {
 export const clipboardStatusAtom = atom<string | null>(null)
 let clipboardStatusTimer: NodeJS.Timeout | null = null
 
+/**
+ * Tracks the number of image paste operations currently in progress.
+ * When > 0, input should be disabled and a loader should be shown.
+ * Uses a counter instead of boolean to support multiple concurrent pastes.
+ */
+export const pendingImagePastesAtom = atom<number>(0)
+
+/**
+ * Tracks the number of text paste operations currently in progress.
+ * When > 0, input should be disabled and a loader should be shown.
+ * Uses a counter instead of boolean to support multiple concurrent pastes.
+ */
+export const pendingTextPastesAtom = atom<number>(0)
+
 function setClipboardStatusWithTimeout(set: Setter, message: string, timeoutMs: number): void {
 	if (clipboardStatusTimer) {
 		clearTimeout(clipboardStatusTimer)
@@ -851,7 +865,7 @@ async function handleShellKeys(get: Getter, set: Setter, key: Key): Promise<void
  * Unified text input keyboard handler
  * Handles both normal (single-line) and multiline text input
  */
-function handleTextInputKeys(get: Getter, set: Setter, key: Key) {
+function handleTextInputKeys(get: Getter, set: Setter, key: Key): void {
 	// Check if we should enter history mode
 	const isEmpty = get(textBufferIsEmptyAtom)
 	const isInHistoryMode = get(historyModeAtom)
@@ -983,14 +997,17 @@ function handleTextInputKeys(get: Getter, set: Setter, key: Key) {
 	}
 
 	if (key.paste) {
-		handlePaste(set, key.sequence)
+		// Fire-and-forget async paste with error handling
+		handlePaste(set, key.sequence).catch((err) =>
+			logs.error("Unhandled text paste error", "clipboard", { error: err }),
+		)
 		return
 	}
 
 	return
 }
 
-function handlePaste(set: Setter, text: string): void {
+async function handlePaste(set: Setter, text: string): Promise<void> {
 	// Quick line count check - avoid processing large text unnecessarily
 	let lineCount = 0
 	for (let i = 0; i < text.length; i++) {
@@ -999,16 +1016,31 @@ function handlePaste(set: Setter, text: string): void {
 	}
 	lineCount++ // Account for last line (no trailing newline)
 
-	if (lineCount >= PASTE_LINE_THRESHOLD) {
-		// Store original text - normalize tabs only when expanding
-		const actualLineCount = text.split("\n").length
-		const refNumber = set(addPastedTextReferenceAtom, text)
-		const reference = formatPastedTextReference(refNumber, actualLineCount)
-		set(insertTextAtom, reference + " ")
-	} else {
-		// Small paste - normalize tabs to prevent border corruption
-		const normalizedText = text.replace(/\t/g, "  ")
-		set(insertTextAtom, normalizedText)
+	const isLargePaste = lineCount >= PASTE_LINE_THRESHOLD
+
+	// For large pastes, show loader and block input
+	if (isLargePaste) {
+		set(pendingTextPastesAtom, (count) => count + 1)
+		// Allow React to render the loader before processing
+		await new Promise((resolve) => setTimeout(resolve, 0))
+	}
+
+	try {
+		if (isLargePaste) {
+			// Store original text - normalize tabs only when expanding
+			const actualLineCount = text.split("\n").length
+			const refNumber = set(addPastedTextReferenceAtom, text)
+			const reference = formatPastedTextReference(refNumber, actualLineCount)
+			set(insertTextAtom, reference + " ")
+		} else {
+			// Small paste - normalize tabs to prevent border corruption
+			const normalizedText = text.replace(/\t/g, "  ")
+			set(insertTextAtom, normalizedText)
+		}
+	} finally {
+		if (isLargePaste) {
+			set(pendingTextPastesAtom, (count) => Math.max(0, count - 1))
+		}
 	}
 }
 
@@ -1122,6 +1154,11 @@ export const triggerClipboardImagePasteAtom = atom(null, async (get, set, fallba
  */
 async function handleClipboardImagePaste(get: Getter, set: Setter, fallbackText?: string): Promise<void> {
 	logs.debug("handleClipboardImagePaste called", "clipboard")
+
+	// Increment pending paste counter to show loader and block input
+	// Using a counter allows multiple concurrent pastes - loader only hides when all complete
+	set(pendingImagePastesAtom, (count) => count + 1)
+
 	try {
 		// Check if clipboard has an image
 		logs.debug("Checking clipboard for image...", "clipboard")
@@ -1183,6 +1220,10 @@ async function handleClipboardImagePaste(get: Getter, set: Setter, fallbackText?
 			`Clipboard error: ${error instanceof Error ? error.message : String(error)}`,
 			3000,
 		)
+	} finally {
+		// Decrement pending paste counter when done
+		// Loader only hides when all concurrent pastes complete (counter reaches 0)
+		set(pendingImagePastesAtom, (count) => Math.max(0, count - 1))
 	}
 }
 
@@ -1196,7 +1237,14 @@ export const keyboardHandlerAtom = atom(null, async (get, set, key: Key) => {
 		return
 	}
 
-	// Priority 2: Determine current mode and route to mode-specific handler
+	// Priority 2: Block input while pasting images or text
+	// This prevents user input during async paste processing
+	// Uses counters to support multiple concurrent pastes
+	if (get(pendingImagePastesAtom) > 0 || get(pendingTextPastesAtom) > 0) {
+		return
+	}
+
+	// Priority 3: Determine current mode and route to mode-specific handler
 	const isApprovalPending = get(isApprovalPendingAtom)
 	const isFollowupVisible = get(followupSuggestionsMenuVisibleAtom)
 	const isAutocompleteVisible = get(showAutocompleteAtom)

+ 126 - 0
cli/src/state/hooks/__tests__/useCIMode.test.tsx

@@ -0,0 +1,126 @@
+/**
+ * Tests for useCIMode hook behavior
+ */
+
+import React from "react"
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import { createStore } from "jotai"
+import { Provider } from "jotai"
+import { render } from "ink-testing-library"
+import { useCIMode } from "../useCIMode.js"
+import { chatMessagesAtom, taskResumedViaContinueOrSessionAtom } from "../../atoms/extension.js"
+import { ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom, ciExitReasonAtom } from "../../atoms/ci.js"
+import type { ExtensionChatMessage } from "../../../types/messages.js"
+
+vi.mock("../../../services/logs.js", () => ({
+	logs: {
+		info: vi.fn(),
+		debug: vi.fn(),
+		warn: vi.fn(),
+		error: vi.fn(),
+	},
+}))
+
+const noop = () => {}
+
+const TestComponent = ({ enabled }: { enabled: boolean }) => {
+	useCIMode({ enabled, onExit: noop })
+	return null
+}
+
+describe("useCIMode", () => {
+	let store: ReturnType<typeof createStore>
+
+	beforeEach(() => {
+		store = createStore()
+	})
+
+	it("skips historical completion_result after session resume", async () => {
+		const completionMessage: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "completion_result",
+			text: "Completed",
+		}
+
+		store.set(taskResumedViaContinueOrSessionAtom, true)
+		store.set(ciCompletionIgnoreBeforeTimestampAtom, completionMessage.ts)
+		store.set(chatMessagesAtom, [completionMessage])
+
+		const { unmount } = render(
+			<Provider store={store}>
+				<TestComponent enabled={true} />
+			</Provider>,
+		)
+
+		await new Promise((resolve) => setTimeout(resolve, 0))
+
+		expect(store.get(ciExitReasonAtom)).toBeNull()
+
+		unmount()
+	})
+
+	it("exits on completion_result when not ignored", async () => {
+		const completionMessage: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "completion_result",
+			text: "Completed",
+		}
+
+		store.set(taskResumedViaContinueOrSessionAtom, false)
+		store.set(ciCompletionIgnoreBeforeTimestampAtom, 0)
+		store.set(chatMessagesAtom, [completionMessage])
+
+		const { unmount } = render(
+			<Provider store={store}>
+				<TestComponent enabled={true} />
+			</Provider>,
+		)
+
+		await new Promise((resolve) => setTimeout(resolve, 0))
+
+		expect(store.get(ciExitReasonAtom)).toBe("completion_result")
+
+		unmount()
+	})
+
+	it("exits when a new completion_result arrives after the ignore timestamp", async () => {
+		const historicalTs = Date.now()
+		const historicalMessage: ExtensionChatMessage = {
+			ts: historicalTs,
+			type: "ask",
+			ask: "completion_result",
+			text: "Completed",
+		}
+
+		store.set(taskResumedViaContinueOrSessionAtom, false)
+		store.set(ciCompletionIgnoreBeforeTimestampAtom, historicalTs)
+		store.set(chatMessagesAtom, [historicalMessage])
+
+		const { unmount } = render(
+			<Provider store={store}>
+				<TestComponent enabled={true} />
+			</Provider>,
+		)
+
+		await new Promise((resolve) => setTimeout(resolve, 0))
+		expect(store.get(ciExitReasonAtom)).toBeNull()
+
+		const newMessage: ExtensionChatMessage = {
+			ts: historicalTs + 1000,
+			type: "ask",
+			ask: "completion_result",
+			text: "Completed again",
+		}
+
+		store.set(chatMessagesAtom, [historicalMessage, newMessage])
+		await new Promise((resolve) => setTimeout(resolve, 0))
+		store.set(ciCompletionDetectedAtom, true)
+
+		await new Promise((resolve) => setTimeout(resolve, 0))
+		expect(store.get(ciExitReasonAtom)).toBe("completion_result")
+
+		unmount()
+	})
+})

+ 182 - 0
cli/src/state/hooks/__tests__/useSessionCost.test.ts

@@ -0,0 +1,182 @@
+/**
+ * Tests for useSessionCost hook
+ */
+
+import { describe, it, expect, beforeEach } from "vitest"
+import { createStore } from "jotai"
+import { chatMessagesAtom } from "../../atoms/extension.js"
+import type { ExtensionChatMessage } from "../../../types/messages.js"
+import { formatSessionCost } from "../useSessionCost.js"
+
+describe("useSessionCost", () => {
+	let store: ReturnType<typeof createStore>
+
+	beforeEach(() => {
+		store = createStore()
+	})
+
+	describe("formatSessionCost", () => {
+		it("should format zero cost", () => {
+			expect(formatSessionCost(0)).toBe("$0.00")
+		})
+
+		it("should format costs with 2 decimal places", () => {
+			expect(formatSessionCost(0.0001)).toBe("$0.00")
+			expect(formatSessionCost(0.0012)).toBe("$0.00")
+			expect(formatSessionCost(0.0099)).toBe("$0.01")
+			expect(formatSessionCost(0.01)).toBe("$0.01")
+			expect(formatSessionCost(0.12)).toBe("$0.12")
+			expect(formatSessionCost(1.23)).toBe("$1.23")
+			expect(formatSessionCost(10.5)).toBe("$10.50")
+		})
+	})
+
+	describe("cost calculation from messages", () => {
+		it("should return zero when no messages", () => {
+			store.set(chatMessagesAtom, [])
+			const messages = store.get(chatMessagesAtom)
+			const result = calculateSessionCost(messages)
+			expect(result.totalCost).toBe(0)
+			expect(result.requestCount).toBe(0)
+			expect(result.hasCostData).toBe(false)
+		})
+
+		it("should calculate total cost from api_req_started messages", () => {
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: 1000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.01 }),
+				},
+				{
+					ts: 2000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.02 }),
+				},
+				{
+					ts: 3000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.005 }),
+				},
+			]
+			store.set(chatMessagesAtom, messages)
+			const result = calculateSessionCost(store.get(chatMessagesAtom))
+			expect(result.totalCost).toBeCloseTo(0.035, 4)
+			expect(result.requestCount).toBe(3)
+			expect(result.hasCostData).toBe(true)
+		})
+
+		it("should ignore api_req_started messages without cost", () => {
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: 1000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.01 }),
+				},
+				{
+					ts: 2000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ request: "test" }), // No cost field
+				},
+				{
+					ts: 3000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.02 }),
+				},
+			]
+			store.set(chatMessagesAtom, messages)
+			const result = calculateSessionCost(store.get(chatMessagesAtom))
+			expect(result.totalCost).toBeCloseTo(0.03, 4)
+			expect(result.requestCount).toBe(2)
+			expect(result.hasCostData).toBe(true)
+		})
+
+		it("should ignore non-api_req_started messages", () => {
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: 1000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.01 }),
+				},
+				{
+					ts: 2000,
+					type: "say",
+					say: "text",
+					text: "Hello world",
+				},
+				{
+					ts: 3000,
+					type: "ask",
+					ask: "tool",
+					text: JSON.stringify({ tool: "readFile" }),
+				},
+			]
+			store.set(chatMessagesAtom, messages)
+			const result = calculateSessionCost(store.get(chatMessagesAtom))
+			expect(result.totalCost).toBeCloseTo(0.01, 4)
+			expect(result.requestCount).toBe(1)
+			expect(result.hasCostData).toBe(true)
+		})
+
+
+		it("should handle messages with empty text", () => {
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: 1000,
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({ cost: 0.01 }),
+				},
+				{
+					ts: 2000,
+					type: "say",
+					say: "api_req_started",
+					text: "",
+				},
+				{
+					ts: 3000,
+					type: "say",
+					say: "api_req_started",
+					// text is undefined
+				},
+			]
+			store.set(chatMessagesAtom, messages)
+			const result = calculateSessionCost(store.get(chatMessagesAtom))
+			expect(result.totalCost).toBeCloseTo(0.01, 4)
+			expect(result.requestCount).toBe(1)
+			expect(result.hasCostData).toBe(true)
+		})
+	})
+})
+
+/**
+ * Helper function to calculate session cost from messages
+ * This mirrors the logic in useSessionCost hook for testing
+ */
+function calculateSessionCost(messages: ExtensionChatMessage[]) {
+	let totalCost = 0
+	let requestCount = 0
+
+	for (const message of messages) {
+		if (message.say === "api_req_started" && message.text) {
+			const data = JSON.parse(message.text)
+			if (typeof data.cost === "number") {
+				totalCost += data.cost
+				requestCount++
+			}
+		}
+	}
+
+	return {
+		totalCost,
+		requestCount,
+		hasCostData: requestCount > 0,
+	}
+}

+ 97 - 4
cli/src/state/hooks/__tests__/useStdinJsonHandler.test.ts

@@ -8,19 +8,33 @@
 import { describe, it, expect, vi, beforeEach } from "vitest"
 import { handleStdinMessage, type StdinMessage, type StdinMessageHandlers } from "../useStdinJsonHandler.js"
 
+// Mock the image-utils module which is used by useStdinJsonHandler
+vi.mock("../../../media/image-utils.js", () => ({
+	convertImagesToDataUrls: vi.fn().mockImplementation(async (images: string[] | undefined) => {
+		if (!images || images.length === 0) return { images: [], errors: [] }
+		const convertedImages = images.map((img) =>
+			img.startsWith("data:") ? img : `data:image/png;base64,mock-${img.replace(/[^a-zA-Z0-9]/g, "")}`,
+		)
+		return { images: convertedImages, errors: [] }
+	}),
+}))
+
 describe("handleStdinMessage", () => {
 	let handlers: StdinMessageHandlers
 	let sendAskResponse: ReturnType<typeof vi.fn>
+	let sendTask: ReturnType<typeof vi.fn>
 	let cancelTask: ReturnType<typeof vi.fn>
 	let respondToTool: ReturnType<typeof vi.fn>
 
 	beforeEach(() => {
 		sendAskResponse = vi.fn().mockResolvedValue(undefined)
+		sendTask = vi.fn().mockResolvedValue(undefined)
 		cancelTask = vi.fn().mockResolvedValue(undefined)
 		respondToTool = vi.fn().mockResolvedValue(undefined)
 
 		handlers = {
 			sendAskResponse,
+			sendTask,
 			cancelTask,
 			respondToTool,
 		}
@@ -44,7 +58,7 @@ describe("handleStdinMessage", () => {
 			expect(respondToTool).not.toHaveBeenCalled()
 		})
 
-		it("should call sendAskResponse with images when provided", async () => {
+		it("should call sendAskResponse with images converted to data URLs when file paths provided", async () => {
 			const message: StdinMessage = {
 				type: "askResponse",
 				askResponse: "messageResponse",
@@ -57,7 +71,26 @@ describe("handleStdinMessage", () => {
 			expect(sendAskResponse).toHaveBeenCalledWith({
 				response: "messageResponse",
 				text: "check this",
-				images: ["img1.png", "img2.png"],
+				images: ["data:image/png;base64,mock-img1png", "data:image/png;base64,mock-img2png"],
+			})
+		})
+
+		it("should pass through data URLs unchanged", async () => {
+			const dataUrl =
+				"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+			const message: StdinMessage = {
+				type: "askResponse",
+				askResponse: "messageResponse",
+				text: "check this",
+				images: [dataUrl],
+			}
+
+			await handleStdinMessage(message, handlers)
+
+			expect(sendAskResponse).toHaveBeenCalledWith({
+				response: "messageResponse",
+				text: "check this",
+				images: [dataUrl],
 			})
 		})
 
@@ -108,7 +141,7 @@ describe("handleStdinMessage", () => {
 			})
 		})
 
-		it("should include images for yesButtonClicked", async () => {
+		it("should include images converted to data URLs for yesButtonClicked", async () => {
 			const message: StdinMessage = {
 				type: "askResponse",
 				askResponse: "yesButtonClicked",
@@ -119,7 +152,67 @@ describe("handleStdinMessage", () => {
 
 			expect(respondToTool).toHaveBeenCalledWith({
 				response: "yesButtonClicked",
-				images: ["screenshot.png"],
+				images: ["data:image/png;base64,mock-screenshotpng"],
+			})
+		})
+	})
+
+	describe("newTask messages", () => {
+		it("should call sendTask with text", async () => {
+			const message: StdinMessage = {
+				type: "newTask",
+				text: "Start a new task",
+			}
+
+			const result = await handleStdinMessage(message, handlers)
+
+			expect(result.handled).toBe(true)
+			expect(sendTask).toHaveBeenCalledWith({
+				text: "Start a new task",
+			})
+		})
+
+		it("should call sendTask with images converted to data URLs", async () => {
+			const message: StdinMessage = {
+				type: "newTask",
+				text: "Check this image",
+				images: ["/tmp/screenshot.png"],
+			}
+
+			await handleStdinMessage(message, handlers)
+
+			expect(sendTask).toHaveBeenCalledWith({
+				text: "Check this image",
+				images: ["data:image/png;base64,mock-tmpscreenshotpng"],
+			})
+		})
+
+		it("should pass through data URLs unchanged in newTask", async () => {
+			const dataUrl =
+				"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+			const message: StdinMessage = {
+				type: "newTask",
+				text: "Check this",
+				images: [dataUrl],
+			}
+
+			await handleStdinMessage(message, handlers)
+
+			expect(sendTask).toHaveBeenCalledWith({
+				text: "Check this",
+				images: [dataUrl],
+			})
+		})
+
+		it("should default to empty text when text is undefined", async () => {
+			const message: StdinMessage = {
+				type: "newTask",
+			}
+
+			await handleStdinMessage(message, handlers)
+
+			expect(sendTask).toHaveBeenCalledWith({
+				text: "",
 			})
 		})
 	})

+ 4 - 0
cli/src/state/hooks/index.ts

@@ -69,3 +69,7 @@ export type { UseFollowupSuggestionsReturn } from "./useFollowupSuggestions.js"
 export { useFollowupCIResponse } from "./useFollowupCIResponse.js"
 export { useTerminal } from "./useTerminal.js"
 export { useFollowupHandler } from "./useFollowupHandler.js"
+
+// Session cost hooks
+export { useSessionCost, formatSessionCost } from "./useSessionCost.js"
+export type { SessionCostInfo } from "./useSessionCost.js"

+ 25 - 4
cli/src/state/hooks/useCIMode.ts

@@ -5,7 +5,13 @@
 
 import { useAtomValue, useSetAtom } from "jotai"
 import { useEffect, useState, useCallback, useRef } from "react"
-import { ciCompletionDetectedAtom, ciCommandFinishedAtom, ciExitReasonAtom } from "../atoms/ci.js"
+import {
+	ciCompletionDetectedAtom,
+	ciCompletionIgnoreBeforeTimestampAtom,
+	ciCommandFinishedAtom,
+	ciExitReasonAtom,
+} from "../atoms/ci.js"
+import { taskResumedViaContinueOrSessionAtom } from "../atoms/extension.js"
 import { useExtensionMessage } from "./useExtensionMessage.js"
 import { logs } from "../../services/logs.js"
 
@@ -67,8 +73,9 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
 	const { enabled, timeout } = options
 
 	const completionDetected = useAtomValue(ciCompletionDetectedAtom)
+	const completionIgnoreBeforeTimestamp = useAtomValue(ciCompletionIgnoreBeforeTimestampAtom)
 	const commandFinished = useAtomValue(ciCommandFinishedAtom)
-
+	const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom)
 	// Write atoms
 	const setCommandFinished = useSetAtom(ciCommandFinishedAtom)
 	const setExitReason = useSetAtom(ciExitReasonAtom)
@@ -86,10 +93,18 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
 	// Get extension messages to monitor for completion_result
 	const { lastMessage } = useExtensionMessage()
 
+	const isHistoricalCompletion = lastMessage?.ts !== undefined && lastMessage.ts <= completionIgnoreBeforeTimestamp
+
 	// Monitor for completion_result messages
 	useEffect(() => {
 		if (!enabled || !lastMessage || exitTriggeredRef.current) return
 
+		// Skip if session was just restored - the completion_result is historical
+		if (taskResumedViaSession || isHistoricalCompletion) {
+			logs.debug("CI mode: Skipping completion_result check - historical message", "useCIMode")
+			return
+		}
+
 		// Check if this is a completion_result message
 		if (lastMessage.type === "ask" && lastMessage.ask === "completion_result") {
 			logs.info("CI mode: completion_result message received", "useCIMode")
@@ -98,7 +113,7 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
 			setShouldExit(true)
 			exitTriggeredRef.current = true
 		}
-	}, [enabled, lastMessage])
+	}, [enabled, lastMessage, taskResumedViaSession, isHistoricalCompletion])
 
 	// Monitor for command finished
 	useEffect(() => {
@@ -115,12 +130,18 @@ export function useCIMode(options: UseCIModeOptions): UseCIModeReturn {
 	useEffect(() => {
 		if (!enabled || !completionDetected || exitTriggeredRef.current) return
 
+		// Skip if session was just restored - the completion_result is historical
+		if (taskResumedViaSession || isHistoricalCompletion) {
+			logs.debug("CI mode: Skipping completion detected atom - historical message", "useCIMode")
+			return
+		}
+
 		logs.info("CI mode: completion detected via atom", "useCIMode")
 		setLocalExitReason("completion_result")
 		setExitReason("completion_result")
 		setShouldExit(true)
 		exitTriggeredRef.current = true
-	}, [enabled, completionDetected])
+	}, [enabled, completionDetected, taskResumedViaSession, isHistoricalCompletion])
 
 	// Setup timeout if provided
 	useEffect(() => {

+ 55 - 0
cli/src/state/hooks/useSessionCost.ts

@@ -0,0 +1,55 @@
+/**
+ * Hook to calculate total session cost from api_req_started messages
+ * Aggregates all API request costs into a single session total
+ */
+
+import { useMemo } from "react"
+import { useAtomValue } from "jotai"
+import { chatMessagesAtom } from "../atoms/extension.js"
+
+export interface SessionCostInfo {
+	/** Total cost of all API requests in the session */
+	totalCost: number
+	/** Number of completed API requests */
+	requestCount: number
+	/** Whether any cost data is available */
+	hasCostData: boolean
+}
+
+/**
+ * Calculate total session cost from all api_req_started messages
+ * Only counts completed requests (those with a cost field)
+ */
+export function useSessionCost(): SessionCostInfo {
+	const messages = useAtomValue(chatMessagesAtom)
+
+	return useMemo(() => {
+		let totalCost = 0
+		let requestCount = 0
+
+		for (const message of messages) {
+			if (message.say === "api_req_started" && message.text) {
+				const data = JSON.parse(message.text)
+				if (typeof data.cost === "number") {
+					totalCost += data.cost
+					requestCount++
+				}
+			}
+		}
+
+		return {
+			totalCost,
+			requestCount,
+			hasCostData: requestCount > 0,
+		}
+	}, [messages])
+}
+
+/**
+ * Format cost for display
+ * @param cost - Cost in dollars
+ * @returns Formatted cost string (e.g., "$1.23")
+ */
+export function formatSessionCost(cost: number): string {
+	return `$${cost.toFixed(2)}`
+}

+ 62 - 5
cli/src/state/hooks/useStdinJsonHandler.ts

@@ -6,8 +6,9 @@
 import { useEffect } from "react"
 import { useSetAtom } from "jotai"
 import { createInterface } from "readline"
-import { sendAskResponseAtom, cancelTaskAtom, respondToToolAtom } from "../atoms/actions.js"
+import { sendAskResponseAtom, sendTaskAtom, cancelTaskAtom, respondToToolAtom } from "../atoms/actions.js"
 import { logs } from "../../services/logs.js"
+import { convertImagesToDataUrls } from "../../media/image-utils.js"
 
 export interface StdinMessage {
 	type: string
@@ -19,6 +20,7 @@ export interface StdinMessage {
 
 export interface StdinMessageHandlers {
 	sendAskResponse: (params: { response: "messageResponse"; text?: string; images?: string[] }) => Promise<void>
+	sendTask: (params: { text: string; images?: string[] }) => Promise<void>
 	cancelTask: () => Promise<void>
 	respondToTool: (params: {
 		response: "yesButtonClicked" | "noButtonClicked"
@@ -30,28 +32,79 @@ export interface StdinMessageHandlers {
 /**
  * Handles a parsed stdin message by calling the appropriate handler.
  * Exported for testing purposes.
+ *
+ * Images can be provided as either:
+ * - Data URLs (e.g., "data:image/png;base64,...")
+ * - File paths (e.g., "/tmp/image.png" or "./screenshot.png")
+ *
+ * File paths are automatically converted to data URLs before being sent.
  */
+/**
+ * Output a JSON message to stdout for the Agent Manager to consume.
+ * Used for error notifications and other structured output.
+ */
+function outputJsonMessage(message: Record<string, unknown>): void {
+	console.log(JSON.stringify(message))
+}
+
 export async function handleStdinMessage(
 	message: StdinMessage,
 	handlers: StdinMessageHandlers,
 ): Promise<{ handled: boolean; error?: string }> {
 	switch (message.type) {
-		case "askResponse":
+		case "newTask": {
+			// Start a new task with prompt and optional images
+			// This allows the Agent Manager to send the initial prompt via stdin
+			// instead of CLI args, enabling images to be included with the first message
+			// Images are converted from file paths to data URLs if needed
+			const result = await convertImagesToDataUrls(message.images)
+
+			// Notify if some images failed to load
+			if (result.errors.length > 0) {
+				outputJsonMessage({
+					type: "image_load_error",
+					errors: result.errors,
+					message: `Failed to load ${result.errors.length} image(s): ${result.errors.map((e) => e.path).join(", ")}`,
+				})
+			}
+
+			await handlers.sendTask({
+				text: message.text || "",
+				...(result.images.length > 0 && { images: result.images }),
+			})
+			return { handled: true }
+		}
+
+		case "askResponse": {
 			// Handle ask response (user message, approval response, etc.)
+			// Images are converted from file paths to data URLs if needed
+			const result = await convertImagesToDataUrls(message.images)
+
+			// Notify if some images failed to load
+			if (result.errors.length > 0) {
+				outputJsonMessage({
+					type: "image_load_error",
+					errors: result.errors,
+					message: `Failed to load ${result.errors.length} image(s): ${result.errors.map((e) => e.path).join(", ")}`,
+				})
+			}
+
+			const images = result.images.length > 0 ? result.images : undefined
 			if (message.askResponse === "yesButtonClicked" || message.askResponse === "noButtonClicked") {
 				await handlers.respondToTool({
 					response: message.askResponse,
 					...(message.text !== undefined && { text: message.text }),
-					...(message.images !== undefined && { images: message.images }),
+					...(images && { images }),
 				})
 			} else {
 				await handlers.sendAskResponse({
 					response: (message.askResponse as "messageResponse") ?? "messageResponse",
 					...(message.text !== undefined && { text: message.text }),
-					...(message.images !== undefined && { images: message.images }),
+					...(images && { images }),
 				})
 			}
 			return { handled: true }
+		}
 
 		case "cancelTask":
 			await handlers.cancelTask()
@@ -80,6 +133,7 @@ export async function handleStdinMessage(
 
 export function useStdinJsonHandler(enabled: boolean) {
 	const sendAskResponse = useSetAtom(sendAskResponseAtom)
+	const sendTask = useSetAtom(sendTaskAtom)
 	const cancelTask = useSetAtom(cancelTaskAtom)
 	const respondToTool = useSetAtom(respondToToolAtom)
 
@@ -99,6 +153,9 @@ export function useStdinJsonHandler(enabled: boolean) {
 			sendAskResponse: async (params) => {
 				await sendAskResponse(params)
 			},
+			sendTask: async (params) => {
+				await sendTask(params)
+			},
 			cancelTask: async () => {
 				await cancelTask()
 			},
@@ -142,5 +199,5 @@ export function useStdinJsonHandler(enabled: boolean) {
 		return () => {
 			rl.close()
 		}
-	}, [enabled, sendAskResponse, cancelTask, respondToTool])
+	}, [enabled, sendAskResponse, sendTask, cancelTask, respondToTool])
 }

+ 48 - 12
cli/src/ui/UI.tsx

@@ -8,9 +8,9 @@ import { Box, Text } from "ink"
 import { useAtomValue, useSetAtom } from "jotai"
 import { isStreamingAtom, errorAtom, addMessageAtom, messageResetCounterAtom, yoloModeAtom } from "../state/atoms/ui.js"
 import { processImagePaths } from "../media/images.js"
-import { setCIModeAtom } from "../state/atoms/ci.js"
+import { setCIModeAtom, ciCompletionDetectedAtom, ciCompletionIgnoreBeforeTimestampAtom } from "../state/atoms/ci.js"
 import { configValidationAtom } from "../state/atoms/config.js"
-import { taskResumedViaContinueOrSessionAtom } from "../state/atoms/extension.js"
+import { lastChatMessageAtom, taskResumedViaContinueOrSessionAtom } from "../state/atoms/extension.js"
 import { useTaskState } from "../state/hooks/useTaskState.js"
 import { isParallelModeAtom } from "../state/atoms/index.js"
 import { addToHistoryAtom, resetHistoryNavigationAtom, exitHistoryModeAtom } from "../state/atoms/history.js"
@@ -20,6 +20,7 @@ import { CommandInput } from "./components/CommandInput.js"
 import { StatusBar } from "./components/StatusBar.js"
 import { StatusIndicator } from "./components/StatusIndicator.js"
 import { initializeCommands } from "../commands/index.js"
+import { initializeCustomCommands } from "../commands/custom.js"
 import { isCommandInput } from "../services/autocomplete.js"
 import { useCommandHandler } from "../state/hooks/useCommandHandler.js"
 import { useMessageHandler } from "../state/hooks/useMessageHandler.js"
@@ -39,8 +40,9 @@ import { workspacePathAtom } from "../state/atoms/shell.js"
 import { useTerminal } from "../state/hooks/useTerminal.js"
 import { exitRequestCounterAtom } from "../state/atoms/keyboard.js"
 import { useWebviewMessage } from "../state/hooks/useWebviewMessage.js"
+import { isResumeAskMessage, shouldWaitForResumeAsk } from "./utils/resumePrompt.js"
 
-// Initialize commands on module load
+// Initialize built-in commands on module load
 initializeCommands()
 
 interface UIAppProps {
@@ -61,12 +63,16 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 	const setCIMode = useSetAtom(setCIModeAtom)
 	const setYoloMode = useSetAtom(yoloModeAtom)
 	const addMessage = useSetAtom(addMessageAtom)
+	const setTaskResumedViaSession = useSetAtom(taskResumedViaContinueOrSessionAtom)
+	const setCiCompletionDetected = useSetAtom(ciCompletionDetectedAtom)
+	const setCiCompletionIgnoreBeforeTimestamp = useSetAtom(ciCompletionIgnoreBeforeTimestampAtom)
 	const addToHistory = useSetAtom(addToHistoryAtom)
 	const resetHistoryNavigation = useSetAtom(resetHistoryNavigationAtom)
 	const exitHistoryMode = useSetAtom(exitHistoryModeAtom)
 	const setIsParallelMode = useSetAtom(isParallelModeAtom)
 	const setWorkspacePath = useSetAtom(workspacePathAtom)
 	const taskResumedViaSession = useAtomValue(taskResumedViaContinueOrSessionAtom)
+	const lastChatMessage = useAtomValue(lastChatMessageAtom)
 	const { hasActiveTask } = useTaskState()
 	const exitRequestCounter = useAtomValue(exitRequestCounterAtom)
 
@@ -77,7 +83,7 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 	})
 
 	// Get sendMessage for sending initial prompt with attachments
-	const { sendMessage } = useWebviewMessage()
+	const { sendMessage, sendAskResponse } = useWebviewMessage()
 
 	// Followup handler hook for automatic suggestion population
 	useFollowupHandler()
@@ -143,11 +149,14 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 		}
 	}, [options.parallel, setIsParallelMode])
 
-	// Initialize workspace path for shell commands
+	// Initialize workspace path for shell commands and load custom commands
 	useEffect(() => {
+		const workspace = options.workspace || process.cwd()
 		if (options.workspace) {
 			setWorkspacePath(options.workspace)
 		}
+		// Load custom commands from ~/.kilocode/commands/ and .kilocode/commands/
+		void initializeCustomCommands(workspace)
 	}, [options.workspace, setWorkspacePath])
 
 	// Handle CI mode exit
@@ -164,10 +173,10 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 	// Execute prompt automatically on mount if provided
 	useEffect(() => {
 		if (options.prompt && !promptExecutedRef.current && configValidation.valid) {
-			// If a session was restored, wait for the task messages to be loaded
-			// This prevents creating a new task instead of continuing the restored one
-			if (taskResumedViaSession && !hasActiveTask) {
-				logs.debug("Waiting for restored session messages to load", "UI")
+			// If a session was restored, wait for the resume ask to arrive
+			// This ensures the prompt answers the resume ask instead of sending too early.
+			if (shouldWaitForResumeAsk(taskResumedViaSession, hasActiveTask, lastChatMessage)) {
+				logs.debug("Waiting for resume ask before executing prompt", "UI")
 				return
 			}
 
@@ -177,10 +186,21 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 			if (trimmedPrompt) {
 				logs.debug("Executing initial prompt", "UI", { prompt: trimmedPrompt })
 
-				// Determine if it's a command or regular message
+				// Clear the session restoration flag after prompt execution starts
+				if (taskResumedViaSession) {
+					logs.debug("Resetting session restoration flags after prompt execution", "UI")
+					setCiCompletionIgnoreBeforeTimestamp(lastChatMessage?.ts ?? Date.now())
+					setTaskResumedViaSession(false)
+					setCiCompletionDetected(false)
+				}
+
+				// Commands are always executed, regardless of resume ask state
+				// This ensures /exit, /clear, etc. work correctly even when resuming a session
 				if (isCommandInput(trimmedPrompt)) {
 					executeCommand(trimmedPrompt, onExit)
 				} else {
+					const shouldAnswerResumeAsk = taskResumedViaSession && isResumeAskMessage(lastChatMessage)
+
 					// Check if there are CLI attachments to load
 					if (options.attachments && options.attachments.length > 0) {
 						// Async IIFE to load attachments and send message
@@ -212,9 +232,19 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 								textLength: trimmedPrompt.length,
 								imageCount: result.images.length,
 							})
-							// Send message with loaded images directly using sendMessage
-							await sendMessage({ type: "newTask", text: trimmedPrompt, images: result.images })
+							// Respond to resume ask if present, otherwise start a new task
+							if (shouldAnswerResumeAsk) {
+								await sendAskResponse({
+									response: "messageResponse",
+									text: trimmedPrompt,
+									images: result.images,
+								})
+							} else {
+								await sendMessage({ type: "newTask", text: trimmedPrompt, images: result.images })
+							}
 						})()
+					} else if (shouldAnswerResumeAsk) {
+						void sendAskResponse({ response: "messageResponse", text: trimmedPrompt })
 					} else {
 						sendUserMessage(trimmedPrompt)
 					}
@@ -224,14 +254,20 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 	}, [
 		options.prompt,
 		options.attachments,
+		options.ci,
 		taskResumedViaSession,
 		hasActiveTask,
 		configValidation.valid,
 		executeCommand,
 		sendUserMessage,
 		sendMessage,
+		sendAskResponse,
 		addMessage,
 		onExit,
+		lastChatMessage,
+		setCiCompletionIgnoreBeforeTimestamp,
+		setTaskResumedViaSession,
+		setCiCompletionDetected,
 	])
 
 	// Simplified submit handler that delegates to appropriate hook

+ 15 - 1
cli/src/ui/components/StatusBar.tsx

@@ -1,5 +1,5 @@
 /**
- * StatusBar component - displays project info, git branch, mode, model, and context usage
+ * StatusBar component - displays project info, git branch, mode, model, context usage, and session cost
  */
 
 import React, { useEffect, useMemo, useState } from "react"
@@ -16,6 +16,7 @@ import {
 } from "../../state/atoms/index.js"
 import { useGitInfo } from "../../state/hooks/useGitInfo.js"
 import { useContextUsage } from "../../state/hooks/useContextUsage.js"
+import { useSessionCost, formatSessionCost } from "../../state/hooks/useSessionCost.js"
 import { useTheme } from "../../state/hooks/useTheme.js"
 import { formatContextUsage } from "../../utils/context.js"
 import {
@@ -111,6 +112,9 @@ export const StatusBar: React.FC = () => {
 	// Calculate context usage
 	const contextUsage = useContextUsage(messages, apiConfig)
 
+	// Calculate session cost
+	const sessionCost = useSessionCost()
+
 	const [isWorktree, setIsWorktree] = useState(false)
 
 	useEffect(() => {
@@ -217,6 +221,16 @@ export const StatusBar: React.FC = () => {
 				<Text color={contextColor} bold>
 					{contextText}
 				</Text>
+
+				{/* Session Cost */}
+				{sessionCost.hasCostData && (
+					<>
+						<Text color={theme.ui.text.dimmed} dimColor>
+							{" | "}
+						</Text>
+						<Text color={theme.semantic.info}>{formatSessionCost(sessionCost.totalCost)}</Text>
+					</>
+				)}
 			</Box>
 		</Box>
 	)

+ 14 - 4
cli/src/ui/components/StatusIndicator.tsx

@@ -12,7 +12,7 @@ import { ThinkingAnimation } from "./ThinkingAnimation.js"
 import { useAtomValue, useSetAtom } from "jotai"
 import { isStreamingAtom, isCancellingAtom } from "../../state/atoms/ui.js"
 import { hasResumeTaskAtom } from "../../state/atoms/extension.js"
-import { exitPromptVisibleAtom } from "../../state/atoms/keyboard.js"
+import { exitPromptVisibleAtom, pendingImagePastesAtom, pendingTextPastesAtom } from "../../state/atoms/keyboard.js"
 import { useEffect } from "react"
 
 /** Safety timeout to auto-reset cancelling state if extension doesn't respond */
@@ -42,6 +42,10 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ disabled = fal
 	const setIsCancelling = useSetAtom(isCancellingAtom)
 	const hasResumeTask = useAtomValue(hasResumeTaskAtom)
 	const exitPromptVisible = useAtomValue(exitPromptVisibleAtom)
+	const pendingImagePastes = useAtomValue(pendingImagePastesAtom)
+	const pendingTextPastes = useAtomValue(pendingTextPastesAtom)
+	const isPastingImage = pendingImagePastes > 0
+	const isPastingText = pendingTextPastes > 0
 	const exitModifierKey = "Ctrl" // Ctrl+C is the universal terminal interrupt signal on all platforms
 
 	// Reset cancelling state when streaming stops
@@ -75,9 +79,15 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ disabled = fal
 					<Text color={theme.semantic.warning}>Press {exitModifierKey}+C again to exit.</Text>
 				) : (
 					<>
-						{isCancelling && <ThinkingAnimation text="Cancelling..." />}
-						{isStreaming && !isCancelling && <ThinkingAnimation />}
-						{hasResumeTask && <Text color={theme.ui.text.dimmed}>Task ready to resume</Text>}
+						{isPastingImage && <ThinkingAnimation text="Pasting image..." />}
+						{isPastingText && !isPastingImage && <ThinkingAnimation text="Pasting text..." />}
+						{isCancelling && !isPastingImage && !isPastingText && (
+							<ThinkingAnimation text="Cancelling..." />
+						)}
+						{isStreaming && !isCancelling && !isPastingImage && !isPastingText && <ThinkingAnimation />}
+						{hasResumeTask && !isPastingImage && !isPastingText && (
+							<Text color={theme.ui.text.dimmed}>Task ready to resume</Text>
+						)}
 					</>
 				)}
 			</Box>

+ 8 - 0
cli/src/ui/components/__tests__/StatusBar.test.tsx

@@ -16,6 +16,14 @@ vi.mock("jotai")
 
 vi.mock("../../../state/hooks/useGitInfo.js")
 vi.mock("../../../state/hooks/useContextUsage.js")
+vi.mock("../../../state/hooks/useSessionCost.js", () => ({
+	useSessionCost: vi.fn(() => ({
+		totalCost: 0,
+		requestCount: 0,
+		hasCostData: false,
+	})),
+	formatSessionCost: vi.fn((cost: number) => `$${cost.toFixed(2)}`),
+}))
 vi.mock("../../../utils/git.js", () => ({
 	isGitWorktree: vi.fn(),
 }))

+ 4 - 3
cli/src/ui/messages/extension/ExtensionMessageRow.tsx

@@ -1,7 +1,7 @@
 import React from "react"
 import { Box, Text } from "ink"
 import type { ExtensionChatMessage } from "../../../types/messages.js"
-import { ErrorBoundary } from "react-error-boundary"
+import { ErrorBoundary, type FallbackProps } from "react-error-boundary"
 import { AskMessageRouter } from "./AskMessageRouter.js"
 import { SayMessageRouter } from "./SayMessageRouter.js"
 import { useTheme } from "../../../state/hooks/useTheme.js"
@@ -11,11 +11,12 @@ interface ExtensionMessageRowProps {
 	message: ExtensionChatMessage
 }
 
-function ErrorFallback({ error }: { error: Error }) {
+function ErrorFallback({ error }: FallbackProps) {
 	const theme = useTheme()
+	const errorMessage = error instanceof Error ? error.message : String(error)
 	return (
 		<Box width={getBoxWidth(1)} borderColor={theme.semantic.error} borderStyle="round" padding={1} marginY={1}>
-			<Text color={theme.semantic.error}>Error rendering message: {error.message}</Text>
+			<Text color={theme.semantic.error}>Error rendering message: {errorMessage}</Text>
 		</Box>
 	)
 }

+ 451 - 0
cli/src/ui/messages/extension/__tests__/diff.test.ts

@@ -509,3 +509,454 @@ line 3`
 		expect(result.length).toBeGreaterThan(0)
 	})
 })
+
+describe("parseDiffContent - partial/streaming markers", () => {
+	describe("should filter out partial SEARCH/REPLACE markers from streaming", () => {
+		it("should filter partial start marker '<<<<'", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old content
+${separator}
+new content
+${replaceMarker}
+<<<<`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the partial marker '<<<<'
+			const hasPartialMarker = result.some((l) => l.content.includes("<<<<"))
+			expect(hasPartialMarker).toBe(false)
+
+			// Should still have the actual diff content
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(1)
+			expect(additions).toHaveLength(1)
+		})
+
+		it("should filter incomplete start marker '<<<<<<< S'", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old content
+${separator}
+new content
+${replaceMarker}
+<<<<<<< S`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the incomplete marker
+			const hasIncompleteMarker = result.some((l) => l.content === "<<<<<<< S")
+			expect(hasIncompleteMarker).toBe(false)
+		})
+
+		it("should filter partial end marker '>>>>>>'", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old content
+${separator}
+new content
+>>>>>>`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the partial marker '>>>>>>'
+			const hasPartialMarker = result.some((l) => l.content === ">>>>>>")
+			expect(hasPartialMarker).toBe(false)
+
+			// Should still have the actual diff content
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(1)
+			expect(additions).toHaveLength(1)
+		})
+
+		it("should filter content that is only partial markers", () => {
+			// This simulates streaming where only partial content has arrived
+			const diff = `<<<<<<< S`
+
+			const result = parseDiffContent(diff)
+
+			// Should return empty or filter out the partial marker
+			const hasPartialMarker = result.some((l) => l.content.startsWith("<<<<"))
+			expect(hasPartialMarker).toBe(false)
+		})
+
+		it("should handle multiple partial markers in streaming content", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old line 1
+old line 2
+${separator}
+new line 1
+${replaceMarker}
+<<<<
+<<<<<<< S
+>>>>>>`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain any partial markers
+			const hasPartialStartMarker = result.some(
+				(l) => l.content.startsWith("<<<<") && !l.content.startsWith("<<<<<<< SEARCH"),
+			)
+			const hasPartialEndMarker = result.some(
+				(l) => l.content.startsWith(">>>>") && !l.content.startsWith(">>>>>>> REPLACE"),
+			)
+			expect(hasPartialStartMarker).toBe(false)
+			expect(hasPartialEndMarker).toBe(false)
+
+			// Should still have the actual diff content
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(2)
+			expect(additions).toHaveLength(1)
+		})
+
+		it("should handle trailing partial marker after valid diff", () => {
+			// Real-world case from the bug report
+			const searchMarker = "<<<<<<< SEARCH"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+"hint": "Prem Enter per enviar, Shift+Enter per nova línia",
+"addImage": "Add image",
+"removeImage": "Remove image"
+${separator}
+"hint": "Prem Enter per enviar, Shift+Enter per nova línia",
+"addImage": "Afegir imatge",
+"removeImage": "Eliminar imatge"
+>>>>>>`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the partial marker
+			const hasPartialMarker = result.some((l) => l.content === ">>>>>>")
+			expect(hasPartialMarker).toBe(false)
+
+			// Should have the correct number of changes
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(3)
+			expect(additions).toHaveLength(3)
+		})
+
+		it("should handle content with only '<<<<' (no complete SEARCH marker)", () => {
+			// When streaming starts and only partial marker has arrived
+			const diff = `<<<<`
+
+			const result = parseDiffContent(diff)
+
+			// Should filter out the partial marker
+			expect(result.every((l) => !l.content.includes("<<<<"))).toBe(true)
+		})
+
+		it("should not filter legitimate content that happens to start with < or >", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+<div>old content</div>
+${separator}
+<div>new content</div>
+${replaceMarker}`
+
+			const result = parseDiffContent(diff)
+
+			// Should preserve the HTML content
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(1)
+			expect(additions).toHaveLength(1)
+			expect(deletions[0].content).toBe("<div>old content</div>")
+			expect(additions[0].content).toBe("<div>new content</div>")
+		})
+
+		it("should handle partial equals marker", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const diff = `${searchMarker}
+-------
+old content
+===`
+
+			const result = parseDiffContent(diff)
+
+			// Partial equals should be filtered if it looks like a marker
+			// But actual content with === should be preserved
+			const deletions = result.filter((l) => l.type === "deletion")
+			expect(deletions).toHaveLength(1)
+			expect(deletions[0].content).toBe("old content")
+		})
+
+		it("should filter git merge conflict start marker '<<<<<<< Updated upstream'", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old content
+${separator}
+new content
+${replaceMarker}
+<<<<<<< Updated upstream`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the git conflict marker
+			const hasGitMarker = result.some((l) => l.content.includes("<<<<<<< Updated upstream"))
+			expect(hasGitMarker).toBe(false)
+
+			// Should still have the actual diff content
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(1)
+			expect(additions).toHaveLength(1)
+		})
+
+		it("should filter git merge conflict end marker '>>>>>>> Stashed changes'", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old content
+${separator}
+new content
+${replaceMarker}
+>>>>>>> Stashed changes`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the git conflict marker
+			const hasGitMarker = result.some((l) => l.content.includes(">>>>>>> Stashed changes"))
+			expect(hasGitMarker).toBe(false)
+
+			// Should still have the actual diff content
+			const deletions = result.filter((l) => l.type === "deletion")
+			const additions = result.filter((l) => l.type === "addition")
+			expect(deletions).toHaveLength(1)
+			expect(additions).toHaveLength(1)
+		})
+
+		it("should filter git merge conflict marker '<<<<<<< HEAD'", () => {
+			const diff = `<<<<<<< HEAD`
+
+			const result = parseDiffContent(diff)
+
+			// Should filter out the git conflict marker
+			const hasGitMarker = result.some((l) => l.content.includes("<<<<<<< HEAD"))
+			expect(hasGitMarker).toBe(false)
+		})
+
+		it("should filter git merge conflict marker with branch name '>>>>>>> feature/branch-name'", () => {
+			const searchMarker = "<<<<<<< SEARCH"
+			const replaceMarker = ">>>>>>> REPLACE"
+			const separator = "======="
+			const diff = `${searchMarker}
+-------
+old content
+${separator}
+new content
+${replaceMarker}
+>>>>>>> feature/branch-name`
+
+			const result = parseDiffContent(diff)
+
+			// Should not contain the git conflict marker
+			const hasGitMarker = result.some((l) => l.content.includes(">>>>>>> feature/branch-name"))
+			expect(hasGitMarker).toBe(false)
+		})
+	})
+})
+
+describe("parseDiffContent - unified diff format with git conflict markers", () => {
+	it("should filter git conflict markers from unified diff deletions", () => {
+		const diff = `@@ -10,7 +10,3 @@
+ import { logs } from "../../services/logs.js"
+-<<<<<<< Updated upstream
+-=======
+-import { convertImagesToDataUrls } from "../../media/image-utils.js"
+->>>>>>> Stashed changes
+ 
+ export interface StdinMessage {`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain any git conflict markers
+		const hasGitMarker = result.some(
+			(l) =>
+				l.content.includes("<<<<<<< Updated upstream") ||
+				l.content.includes(">>>>>>> Stashed changes") ||
+				l.content === "=======",
+		)
+		expect(hasGitMarker).toBe(false)
+
+		// Should still have the legitimate content
+		const hasLegitimateContent = result.some((l) => l.content.includes("import { logs }"))
+		expect(hasLegitimateContent).toBe(true)
+	})
+
+	it("should filter git conflict markers from unified diff additions", () => {
+		const diff = `@@ -1,3 +1,7 @@
+ line 1
++<<<<<<< HEAD
++new content from HEAD
++=======
++new content from branch
++>>>>>>> feature-branch
+ line 2`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain any git conflict markers
+		const hasGitMarker = result.some(
+			(l) =>
+				l.content.includes("<<<<<<< HEAD") ||
+				l.content.includes(">>>>>>> feature-branch") ||
+				l.content === "=======",
+		)
+		expect(hasGitMarker).toBe(false)
+	})
+
+	it("should filter ======= separator from unified diff", () => {
+		const diff = `@@ -1,3 +1,3 @@
+ line 1
+-=======
++new line
+ line 2`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain the separator as content
+		const hasSeparator = result.some((l) => l.content === "=======")
+		expect(hasSeparator).toBe(false)
+
+		// Should have the new line
+		const hasNewLine = result.some((l) => l.content === "new line" && l.type === "addition")
+		expect(hasNewLine).toBe(true)
+	})
+
+	it("should filter escaped git conflict markers (with backslash prefix)", () => {
+		const diff = `@@ -1,7 +1,3 @@
+ import { logs } from "../../services/logs.js"
+-\\<<<<<<< Updated upstream
+-\\=======
+-import { convertImagesToDataUrls } from "../../media/image-utils.js"
+-\\>>>>>>> Stashed changes
+ 
+ export interface StdinMessage {`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain any escaped git conflict markers
+		const hasEscapedMarker = result.some(
+			(l) =>
+				l.content.includes("\\<<<<<<< Updated upstream") ||
+				l.content.includes("\\=======") ||
+				l.content.includes("\\>>>>>>> Stashed changes"),
+		)
+		expect(hasEscapedMarker).toBe(false)
+
+		// Should still have the legitimate content
+		const hasLegitimateContent = result.some((l) => l.content.includes("import { logs }"))
+		expect(hasLegitimateContent).toBe(true)
+	})
+
+	it("should filter escaped partial markers", () => {
+		const diff = `@@ -1,3 +1,1 @@
+	line 1
+-\\<<<<
+-\\>>>>>>`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain escaped partial markers
+		const hasEscapedPartialMarker = result.some((l) => l.content === "\\<<<<" || l.content === "\\>>>>>>")
+		expect(hasEscapedPartialMarker).toBe(false)
+	})
+
+	it("should filter partial markers with leading whitespace (bug report case)", () => {
+		// This is the exact case from the bug report where markers appear with indentation
+		// e.g., "               <<<<" or "               <<<<<<< S"
+		const diff = `@@ -1,3 +1,1 @@
+	line 1
+-               <<<<
+-               <<<<<<< S`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain partial markers even with leading whitespace
+		const hasPartialMarker = result.some((l) => l.content.includes("<<<<") || l.content.includes("<<<<<<< S"))
+		expect(hasPartialMarker).toBe(false)
+	})
+
+	it("should filter markers with tabs and spaces", () => {
+		const diff = `@@ -1,2 +1,1 @@
+	line 1
+-		<<<<`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain partial markers with tab indentation
+		const hasPartialMarker = result.some((l) => l.content.includes("<<<<"))
+		expect(hasPartialMarker).toBe(false)
+	})
+
+	it("should filter partial markers appearing as additions (exact bug report case)", () => {
+		// This reproduces the exact bug from the report where >>>>>> appears as an addition
+		// The output showed: "      92 + >>>>>>"
+		const diff = `@@ -89,3 +89,4 @@
+	"hint": "Prem Enter per enviar, Shift+Enter per nova línia",
+-"addImage": "Add image",
+-"removeImage": "Remove image"
++"hint": "Prem Enter per enviar, Shift+Enter per nova línia",
++"addImage": "Afegir imatge",
++"removeImage": "Eliminar imatge"
++>>>>>>`
+
+		const result = parseDiffContent(diff)
+
+		// Should not contain the partial marker as an addition
+		const hasPartialMarker = result.some((l) => l.type === "addition" && l.content === ">>>>>>")
+		expect(hasPartialMarker).toBe(false)
+
+		// Should still have the legitimate additions
+		const hasLegitimateAddition = result.some((l) => l.type === "addition" && l.content.includes("Afegir imatge"))
+		expect(hasLegitimateAddition).toBe(true)
+	})
+
+	it("should filter partial markers appearing as only content (empty diff case)", () => {
+		// This reproduces the case where the entire diff content is just a partial marker
+		// The output showed: "⏺︎ Update( webview-ui/src/i18n/locales/es/agentManager.json)\n           <<<<"
+		const diff = `           <<<<`
+
+		const result = parseDiffContent(diff)
+
+		// Should return empty or no partial markers
+		const hasPartialMarker = result.some((l) => l.content.includes("<<<<"))
+		expect(hasPartialMarker).toBe(false)
+	})
+
+	it("should filter <<<<<<< S partial marker appearing as only content", () => {
+		// This reproduces: "⏺︎ Update( webview-ui/src/i18n/locales/pt-BR/agentManager.json)\n           <<<<<<< S"
+		const diff = `           <<<<<<< S`
+
+		const result = parseDiffContent(diff)
+
+		// Should return empty or no partial markers
+		const hasPartialMarker = result.some((l) => l.content.includes("<<<<"))
+		expect(hasPartialMarker).toBe(false)
+	})
+})

+ 144 - 9
cli/src/ui/messages/extension/diff.ts

@@ -19,6 +19,85 @@ export function isUnifiedDiffFormat(content: string): boolean {
 	return content.includes("@@") || content.startsWith("---")
 }
 
+/**
+ * Check if a line is a partial/incomplete SEARCH/REPLACE marker from streaming,
+ * or a git merge conflict marker that should be filtered out.
+ *
+ * These markers appear when content is being streamed and should be filtered out.
+ *
+ * Examples of partial markers:
+ * - "<<<<" (partial start of "<<<<<<< SEARCH")
+ * - "<<<<<<< S" (incomplete "<<<<<<< SEARCH")
+ * - ">>>>>>" (partial ">>>>>>> REPLACE")
+ * - "===" (partial "=======")
+ *
+ * Git merge conflict markers (also filtered):
+ * - "<<<<<<< Updated upstream"
+ * - "<<<<<<< HEAD"
+ * - ">>>>>>> Stashed changes"
+ * - ">>>>>>> branch-name"
+ * - "=======" (conflict separator)
+ *
+ * Also handles escaped markers (with backslash prefix):
+ * - "\<<<<<<< Updated upstream"
+ * - "\======="
+ * - "\>>>>>>> Stashed changes"
+ */
+function isPartialSearchReplaceMarker(line: string): boolean {
+	// Strip leading backslash if present (escaped markers)
+	const normalizedLine = line.startsWith("\\") ? line.slice(1) : line
+
+	// Also strip leading/trailing whitespace for detection purposes
+	// This handles cases like "               <<<<" from the bug report
+	const trimmedLine = normalizedLine.trim()
+
+	// Complete SEARCH/REPLACE markers - these are handled by the main parser in parseSearchReplaceFormat
+	// Note: We don't return false for "=======" here because it could be a git conflict separator
+	// that should be filtered when it appears as content in unified diff format
+	if (trimmedLine.startsWith("<<<<<<< SEARCH") || trimmedLine.startsWith(">>>>>>> REPLACE")) {
+		return false
+	}
+
+	// Git merge conflict markers - filter these out completely
+	// They look like "<<<<<<< Updated upstream", "<<<<<<< HEAD", ">>>>>>> Stashed changes", etc.
+	if (/^<{7}\s+\S/.test(trimmedLine)) {
+		// "<<<<<<< " followed by any text (git conflict start marker)
+		return true
+	}
+	if (/^>{7}\s+\S/.test(trimmedLine)) {
+		// ">>>>>>> " followed by any text (git conflict end marker)
+		return true
+	}
+
+	// Partial start marker: lines that are only < characters (1-7) or start with < and look like incomplete marker
+	// But NOT legitimate content like HTML tags "<div>" or comparison operators
+	if (/^<{1,7}$/.test(trimmedLine)) {
+		return true // Just angle brackets like "<<<<" or "<<<<<<<"
+	}
+	if (/^<{4,7}\s*\S*$/.test(trimmedLine) && !trimmedLine.includes(">")) {
+		// Looks like "<<<<<<< S" or "<<<<<<< SE" but not complete "<<<<<<< SEARCH"
+		return true
+	}
+
+	// Partial end marker: lines that are only > characters (1-7) or start with > and look like incomplete marker
+	// But NOT legitimate content like HTML closing tags or shell redirects
+	if (/^>{1,7}$/.test(trimmedLine)) {
+		return true // Just angle brackets like ">>>>>>" or ">>>>>>>"
+	}
+	if (/^>{4,7}\s*\S*$/.test(trimmedLine) && !trimmedLine.includes("<")) {
+		// Looks like ">>>>>>> R" or ">>>>>>> RE" but not complete ">>>>>>> REPLACE"
+		return true
+	}
+
+	// Separator: lines that are only = characters (3-7)
+	// This includes both partial separators and the complete "=======" git conflict separator
+	if (/^={3,7}$/.test(trimmedLine)) {
+		return true
+	}
+
+	return false
+}
+
 function parseSearchReplaceFormat(lines: string[]): ParsedDiffLine[] {
 	const result: ParsedDiffLine[] = []
 	let inSearch = false
@@ -27,28 +106,46 @@ function parseSearchReplaceFormat(lines: string[]): ParsedDiffLine[] {
 	let newLineNum = 1
 
 	for (const line of lines) {
+		// Handle SEARCH/REPLACE format markers first (before filtering)
 		if (line.startsWith("<<<<<<< SEARCH")) {
 			// Skip marker - don't add to result
 			inSearch = true
 			inReplace = false
-		} else if (line.startsWith(":start_line:")) {
+			continue
+		}
+		if (line.startsWith(":start_line:")) {
 			const match = line.match(/:start_line:(\d+)/)
 			if (match && match[1]) {
 				oldLineNum = parseInt(match[1], 10)
 				newLineNum = oldLineNum
 			}
 			// Skip marker - don't add to result
-		} else if (line === "-------") {
+			continue
+		}
+		if (line === "-------") {
 			// Skip marker - don't add to result
-		} else if (line === "=======") {
+			continue
+		}
+		if (line === "=======" && (inSearch || !inReplace)) {
+			// This is the SEARCH/REPLACE separator, not a git conflict marker
 			// Skip marker - don't add to result
 			inSearch = false
 			inReplace = true
-		} else if (line.startsWith(">>>>>>> REPLACE")) {
+			continue
+		}
+		if (line.startsWith(">>>>>>> REPLACE")) {
 			// Skip marker - don't add to result
 			inSearch = false
 			inReplace = false
-		} else if (inSearch) {
+			continue
+		}
+
+		// Filter out partial/incomplete markers from streaming
+		if (isPartialSearchReplaceMarker(line)) {
+			continue
+		}
+
+		if (inSearch) {
 			result.push({ type: "deletion", content: line, oldLineNum: oldLineNum++ })
 		} else if (inReplace) {
 			result.push({ type: "addition", content: line, newLineNum: newLineNum++ })
@@ -76,12 +173,31 @@ function parseUnifiedDiffFormat(lines: string[]): ParsedDiffLine[] {
 		} else if (line.startsWith("---") || line.startsWith("+++")) {
 			result.push({ type: "header", content: line })
 		} else if (line.startsWith("+")) {
-			result.push({ type: "addition", content: line.slice(1), newLineNum: newLineNum++ })
+			const content = line.slice(1)
+			// Filter out partial/incomplete markers and git conflict markers from additions
+			if (isPartialSearchReplaceMarker(content)) {
+				continue
+			}
+			result.push({ type: "addition", content, newLineNum: newLineNum++ })
 		} else if (line.startsWith("-")) {
-			result.push({ type: "deletion", content: line.slice(1), oldLineNum: oldLineNum++ })
+			const content = line.slice(1)
+			// Filter out partial/incomplete markers and git conflict markers from deletions
+			if (isPartialSearchReplaceMarker(content)) {
+				continue
+			}
+			result.push({ type: "deletion", content, oldLineNum: oldLineNum++ })
 		} else if (line.startsWith(" ")) {
-			result.push({ type: "context", content: line.slice(1), oldLineNum: oldLineNum++, newLineNum: newLineNum++ })
+			const content = line.slice(1)
+			// Filter out partial/incomplete markers and git conflict markers from context
+			if (isPartialSearchReplaceMarker(content)) {
+				continue
+			}
+			result.push({ type: "context", content, oldLineNum: oldLineNum++, newLineNum: newLineNum++ })
 		} else {
+			// Filter out partial/incomplete markers and git conflict markers
+			if (isPartialSearchReplaceMarker(line)) {
+				continue
+			}
 			result.push({ type: "context", content: line })
 		}
 	}
@@ -89,11 +205,30 @@ function parseUnifiedDiffFormat(lines: string[]): ParsedDiffLine[] {
 	return result
 }
 
+/**
+ * Check if content looks like SEARCH/REPLACE format (complete or partial).
+ * This helps route streaming content to the correct parser.
+ */
+function isSearchReplaceFormat(content: string): boolean {
+	// Complete marker
+	if (content.includes("<<<<<<< SEARCH")) {
+		return true
+	}
+
+	// Partial markers that indicate SEARCH/REPLACE format during streaming
+	// Look for 4+ consecutive < or > characters at the start of a line
+	if (/^<{4,}/m.test(content) || /^>{4,}/m.test(content)) {
+		return true
+	}
+
+	return false
+}
+
 export function parseDiffContent(diffContent: string): ParsedDiffLine[] {
 	if (!diffContent) return []
 
 	const lines = diffContent.split("\n")
-	const isSearchReplace = diffContent.includes("<<<<<<< SEARCH")
+	const isSearchReplace = isSearchReplaceFormat(diffContent)
 
 	return isSearchReplace ? parseSearchReplaceFormat(lines) : parseUnifiedDiffFormat(lines)
 }

+ 11 - 30
cli/src/ui/messages/extension/say/SayApiReqStartedMessage.tsx

@@ -5,27 +5,25 @@ import { parseApiReqInfo } from "../utils.js"
 import { useTheme } from "../../../../state/hooks/useTheme.js"
 
 /**
- * Display API request status (streaming/completed/failed/cancelled)
+ * Display API request status (only for failed/cancelled states)
+ *
+ * In-progress and completed states are no longer shown individually.
+ * The total session cost is displayed in the StatusBar instead.
+ * This reduces visual noise and provides a cleaner user experience.
  */
 export const SayApiReqStartedMessage: React.FC<MessageComponentProps> = ({ message }) => {
 	const theme = useTheme()
 	const apiInfo = parseApiReqInfo(message)
 
-	// In-progress state
-	// NOTE: api_req_started is often sent as a non-partial placeholder before cost/usage is known.
-	// In the CLI we treat "no completion indicators" as still in progress.
+	// In-progress state - don't show anything (thinking spinner is enough)
 	if (
 		message.partial ||
 		(!apiInfo?.streamingFailedMessage && !apiInfo?.cancelReason && apiInfo?.cost === undefined)
 	) {
-		return (
-			<Box marginY={1}>
-				<Text color={theme.semantic.info}>⟳ API Request in progress...</Text>
-			</Box>
-		)
+		return null
 	}
 
-	// Failed state
+	// Failed state - show error message
 	if (apiInfo?.streamingFailedMessage) {
 		return (
 			<Box flexDirection="column" marginY={1}>
@@ -41,7 +39,7 @@ export const SayApiReqStartedMessage: React.FC<MessageComponentProps> = ({ messa
 		)
 	}
 
-	// Cancelled state
+	// Cancelled state - show cancellation message
 	if (apiInfo?.cancelReason) {
 		return (
 			<Box flexDirection="column" marginY={1}>
@@ -59,23 +57,6 @@ export const SayApiReqStartedMessage: React.FC<MessageComponentProps> = ({ messa
 		)
 	}
 
-	// Completed state
-	return (
-		<Box marginY={1}>
-			<Text color={theme.semantic.success} bold>
-				✓ API Request
-			</Text>
-			{apiInfo?.cost !== undefined && (
-				<>
-					<Text color={theme.semantic.info}> - Cost: ${apiInfo.cost.toFixed(4)}</Text>
-					{apiInfo.usageMissing && (
-						<Text color={theme.ui.text.dimmed} dimColor>
-							{" "}
-							(estimated)
-						</Text>
-					)}
-				</>
-			)}
-		</Box>
-	)
+	// Completed state - don't show anything (total cost shown in StatusBar)
+	return null
 }

+ 53 - 0
cli/src/ui/utils/__tests__/resumePrompt.test.ts

@@ -0,0 +1,53 @@
+import { describe, it, expect } from "vitest"
+import type { ExtensionChatMessage } from "../../../types/messages.js"
+import { isResumeAskMessage, shouldWaitForResumeAsk } from "../resumePrompt.js"
+
+describe("resumePrompt helpers", () => {
+	it("detects resume ask messages", () => {
+		const message: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "resume_task",
+			text: "Resume?",
+		}
+
+		expect(isResumeAskMessage(message)).toBe(true)
+	})
+
+	it("returns false for non-resume ask messages", () => {
+		const message: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "completion_result",
+			text: "Completed",
+		}
+
+		expect(isResumeAskMessage(message)).toBe(false)
+	})
+
+	it("waits when session resumed but no active task yet", () => {
+		expect(shouldWaitForResumeAsk(true, false, null)).toBe(true)
+	})
+
+	it("waits when session resumed and last message is not resume ask", () => {
+		const message: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "completion_result",
+			text: "Completed",
+		}
+
+		expect(shouldWaitForResumeAsk(true, true, message)).toBe(true)
+	})
+
+	it("does not wait when resume ask is present", () => {
+		const message: ExtensionChatMessage = {
+			ts: Date.now(),
+			type: "ask",
+			ask: "resume_completed_task",
+			text: "Resume completed?",
+		}
+
+		expect(shouldWaitForResumeAsk(true, true, message)).toBe(false)
+	})
+})

+ 51 - 0
cli/src/ui/utils/__tests__/terminalCapabilities.test.ts

@@ -145,6 +145,40 @@ describe("terminalCapabilities", () => {
 		})
 	})
 
+	describe("supportsTitleSetting", () => {
+		it("should return true when WT_SESSION is set (Windows Terminal)", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: "some-session-id" })
+			vi.resetModules()
+			const { supportsTitleSetting } = await import("../terminalCapabilities.js")
+			expect(supportsTitleSetting()).toBe(true)
+		})
+
+		it("should return true when TERM_PROGRAM is vscode", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: "vscode" })
+			vi.resetModules()
+			const { supportsTitleSetting } = await import("../terminalCapabilities.js")
+			expect(supportsTitleSetting()).toBe(true)
+		})
+
+		it("should return false on Windows without modern terminal indicators", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { supportsTitleSetting } = await import("../terminalCapabilities.js")
+			expect(supportsTitleSetting()).toBe(false)
+		})
+
+		it("should return true on non-Windows platforms", async () => {
+			mockPlatform("darwin")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { supportsTitleSetting } = await import("../terminalCapabilities.js")
+			expect(supportsTitleSetting()).toBe(true)
+		})
+	})
+
 	describe("getTerminalClearSequence", () => {
 		it("should return Windows-compatible clear sequence on legacy Windows (cmd.exe)", async () => {
 			mockPlatform("win32")
@@ -268,6 +302,23 @@ describe("terminalCapabilities", () => {
 		})
 	})
 
+	describe("detectKittyProtocolSupport", () => {
+		it("should skip detection and return false on legacy Windows (cmd.exe)", async () => {
+			mockPlatform("win32")
+			mockEnv({ WT_SESSION: undefined, TERM_PROGRAM: undefined })
+			vi.resetModules()
+			const { detectKittyProtocolSupport } = await import("../terminalCapabilities.js")
+
+			// On legacy Windows, should immediately return false without sending CSI queries
+			const result = await detectKittyProtocolSupport()
+			expect(result).toBe(false)
+
+			// Should NOT have written any CSI query sequences (which would display as raw text)
+			const csiQueries = writtenData.filter((d) => d.includes("\x1b[?u") || d.includes("\x1b[c"))
+			expect(csiQueries).toHaveLength(0)
+		})
+	})
+
 	describe("Windows cmd.exe display bug regression", () => {
 		/**
 		 * This test verifies the fix for GitHub issue #4697

+ 23 - 0
cli/src/ui/utils/resumePrompt.ts

@@ -0,0 +1,23 @@
+import type { ExtensionChatMessage } from "../../types/messages.js"
+
+const resumeAskTypes = new Set(["resume_task", "resume_completed_task"])
+
+export function isResumeAskMessage(message: ExtensionChatMessage | null): boolean {
+	return message?.type === "ask" && resumeAskTypes.has(message.ask ?? "")
+}
+
+export function shouldWaitForResumeAsk(
+	taskResumedViaSession: boolean,
+	hasActiveTask: boolean,
+	lastChatMessage: ExtensionChatMessage | null,
+): boolean {
+	if (!taskResumedViaSession) {
+		return false
+	}
+
+	if (!hasActiveTask) {
+		return true
+	}
+
+	return !isResumeAskMessage(lastChatMessage)
+}

+ 19 - 0
cli/src/ui/utils/terminalCapabilities.ts

@@ -35,6 +35,17 @@ export function supportsScrollbackClear(): boolean {
 	return !isWindows()
 }
 
+/**
+ * Check if the terminal supports OSC sequences for setting window title (\x1b]0;title\x07)
+ *
+ * Modern terminals support OSC 0 for title setting, but legacy cmd.exe does not.
+ * This uses the same detection logic as scrollback clearing since unsupported
+ * terminals are the same.
+ */
+export function supportsTitleSetting(): boolean {
+	return supportsScrollbackClear()
+}
+
 /**
  * Get the appropriate terminal clear sequence for the current terminal
  *
@@ -105,6 +116,14 @@ export async function detectKittyProtocolSupport(): Promise<boolean> {
 			return
 		}
 
+		// Skip Kitty protocol detection on legacy Windows terminals (cmd.exe)
+		// These terminals don't support the CSI queries and will display raw escape sequences
+		if (!supportsScrollbackClear()) {
+			kittyDetected = true
+			resolve(false)
+			return
+		}
+
 		const originalRawMode = process.stdin.isRaw
 		if (!originalRawMode) {
 			process.stdin.setRawMode(true)

+ 134 - 0
cli/src/utils/__tests__/context.test.ts

@@ -185,6 +185,140 @@ describe("context utilities", () => {
 			expect(result.maxTokens).toBe(200000)
 		})
 
+		it("should skip placeholder api_req_started messages without token data", () => {
+			const apiConfig: ProviderSettings = {
+				apiProvider: "openrouter",
+				openRouterModelId: "anthropic/claude-sonnet-4.5",
+			}
+
+			const routerModels: Partial<RouterModels> = {
+				openrouter: {
+					"anthropic/claude-sonnet-4.5": {
+						contextWindow: 200000,
+						supportsPromptCache: true,
+						maxTokens: 8192,
+					},
+				},
+			}
+
+			// Simulate the real scenario: a placeholder message (only apiProtocol) followed by
+			// the actual message with token data, then another placeholder at the end
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: Date.now(),
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({
+						tokensIn: 1000,
+						tokensOut: 500,
+						apiProtocol: "openai",
+					}),
+				},
+				{
+					ts: Date.now() + 1,
+					type: "say",
+					say: "api_req_started",
+					// Placeholder message - only has apiProtocol, no token data
+					text: JSON.stringify({
+						apiProtocol: "openai",
+					}),
+				},
+			]
+
+			const result = calculateContextUsage(messages, apiConfig, routerModels as RouterModels)
+			// Should skip the placeholder and use the previous message's tokens
+			expect(result.tokensUsed).toBe(1500)
+			expect(result.maxTokens).toBe(200000)
+		})
+
+		it("should handle multiple placeholder messages and find valid token data", () => {
+			const apiConfig: ProviderSettings = {
+				apiProvider: "openrouter",
+				openRouterModelId: "anthropic/claude-sonnet-4.5",
+			}
+
+			const routerModels: Partial<RouterModels> = {
+				openrouter: {
+					"anthropic/claude-sonnet-4.5": {
+						contextWindow: 200000,
+						supportsPromptCache: true,
+						maxTokens: 8192,
+					},
+				},
+			}
+
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: Date.now(),
+					type: "say",
+					say: "api_req_started",
+					text: JSON.stringify({
+						tokensIn: 5000,
+						tokensOut: 2000,
+						cacheWrites: 100,
+						cacheReads: 200,
+						apiProtocol: "anthropic",
+					}),
+				},
+				{
+					ts: Date.now() + 1,
+					type: "say",
+					say: "api_req_started",
+					// First placeholder
+					text: JSON.stringify({
+						apiProtocol: "anthropic",
+					}),
+				},
+				{
+					ts: Date.now() + 2,
+					type: "say",
+					say: "api_req_started",
+					// Second placeholder
+					text: JSON.stringify({
+						apiProtocol: "anthropic",
+					}),
+				},
+			]
+
+			const result = calculateContextUsage(messages, apiConfig, routerModels as RouterModels)
+			// Should skip both placeholders and use the first message's tokens
+			// For Anthropic: tokensIn + tokensOut + cacheWrites + cacheReads
+			expect(result.tokensUsed).toBe(7300)
+		})
+
+		it("should return zero when all api_req_started messages are placeholders", () => {
+			const apiConfig: ProviderSettings = {
+				apiProvider: "openrouter",
+				openRouterModelId: "anthropic/claude-sonnet-4.5",
+			}
+
+			const routerModels: Partial<RouterModels> = {
+				openrouter: {
+					"anthropic/claude-sonnet-4.5": {
+						contextWindow: 200000,
+						supportsPromptCache: true,
+						maxTokens: 8192,
+					},
+				},
+			}
+
+			const messages: ExtensionChatMessage[] = [
+				{
+					ts: Date.now(),
+					type: "say",
+					say: "api_req_started",
+					// Only placeholder messages
+					text: JSON.stringify({
+						apiProtocol: "openai",
+					}),
+				},
+			]
+
+			const result = calculateContextUsage(messages, apiConfig, routerModels as RouterModels)
+			// Should return 0 since no valid token data found
+			expect(result.tokensUsed).toBe(0)
+		})
+
 		it("should return zero tokens when no api_req_started messages exist", () => {
 			const messages: ExtensionChatMessage[] = [
 				{

+ 71 - 0
cli/src/utils/__tests__/env-loader.test.ts

@@ -0,0 +1,71 @@
+/**
+ * Tests for env-loader utilities
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import { existsSync } from "fs"
+import { config } from "dotenv"
+
+// Mock fs and dotenv modules
+vi.mock("fs", () => ({
+	existsSync: vi.fn(),
+}))
+
+vi.mock("dotenv", () => ({
+	config: vi.fn(),
+}))
+
+describe("loadEnvFile", () => {
+	const mockExistsSync = vi.mocked(existsSync)
+	const mockConfig = vi.mocked(config)
+	let loadEnvFile: typeof import("../env-loader.js").loadEnvFile
+
+	beforeEach(async () => {
+		vi.clearAllMocks()
+		vi.resetModules() // Reset module cache to ensure fresh import with mocks
+		// Use vi.spyOn for safer mocking that auto-restores
+		vi.spyOn(process, "exit").mockImplementation(() => undefined as never)
+		vi.spyOn(console, "error").mockImplementation(() => {})
+		// Import module after mocks are set up
+		const module = await import("../env-loader.js")
+		loadEnvFile = module.loadEnvFile
+	})
+
+	afterEach(() => {
+		vi.restoreAllMocks()
+	})
+
+	it("should return early without logging when .env file does not exist", () => {
+		mockExistsSync.mockReturnValue(false)
+
+		loadEnvFile()
+
+		expect(mockExistsSync).toHaveBeenCalledWith(expect.stringContaining(".env"))
+		expect(mockConfig).not.toHaveBeenCalled()
+		expect(console.error).not.toHaveBeenCalled()
+		expect(process.exit).not.toHaveBeenCalled()
+	})
+
+	it("should load .env file when it exists", () => {
+		mockExistsSync.mockReturnValue(true)
+		mockConfig.mockReturnValue({ parsed: { TEST_VAR: "value" } })
+
+		loadEnvFile()
+
+		expect(mockExistsSync).toHaveBeenCalledWith(expect.stringContaining(".env"))
+		expect(mockConfig).toHaveBeenCalledWith({ path: expect.stringContaining(".env") })
+		expect(process.exit).not.toHaveBeenCalled()
+	})
+
+	it("should exit with error when .env file exists but has parsing errors", () => {
+		mockExistsSync.mockReturnValue(true)
+		mockConfig.mockReturnValue({ error: new Error("Parse error") })
+
+		loadEnvFile()
+
+		expect(mockExistsSync).toHaveBeenCalledWith(expect.stringContaining(".env"))
+		expect(mockConfig).toHaveBeenCalledWith({ path: expect.stringContaining(".env") })
+		expect(console.error).toHaveBeenCalledWith("Error loading .env file: Parse error")
+		expect(process.exit).toHaveBeenCalledWith(1)
+	})
+})

+ 8 - 0
cli/src/utils/context.ts

@@ -81,6 +81,14 @@ function getContextTokensFromMessages(messages: ExtensionChatMessage[]): number
 				const parsedText = JSON.parse(message.text)
 				const { tokensIn, tokensOut, cacheWrites, cacheReads, apiProtocol } = parsedText
 
+				// Skip placeholder messages that only have apiProtocol but no token data
+				// These are sent at the start of API requests before the response is received
+				const hasValidTokenData = typeof tokensIn === "number" || typeof tokensOut === "number"
+				if (!hasValidTokenData) {
+					// This is a placeholder message, continue searching backwards
+					continue
+				}
+
 				// Calculate context tokens based on API protocol (matches getApiMetrics logic)
 				if (apiProtocol === "anthropic") {
 					return (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)

+ 4 - 4
cli/src/utils/env-loader.ts

@@ -7,22 +7,22 @@ declare const __dirname: string
 
 /**
  * Loads the .env file from the dist directory (where binaries are located)
- * Throws an error if the .env file doesn't exist
+ * The .env file is optional - users can configure via KILO_* environment variables instead
  */
 export function loadEnvFile(): void {
 	// In bundled output, __dirname points to the dist directory where index.js is located
 	// The .env file should be in the same directory
 	const envPath = join(__dirname, ".env")
 
-	// Check if .env file exists
+	// .env is optional - users can configure via KILO_* environment variables instead
 	if (!existsSync(envPath)) {
-		console.error(`Error: Required .env file not found at: ${envPath}`)
-		process.exit(1)
+		return
 	}
 
 	// Load the .env file
 	const result = config({ path: envPath })
 
+	// If .env exists but has parsing errors, report the error
 	if (result.error) {
 		console.error(`Error loading .env file: ${result.error.message}`)
 		process.exit(1)

+ 9 - 5
cli/src/validation/attachments.ts

@@ -8,16 +8,20 @@ export interface AttachmentValidationResult {
 }
 
 /**
- * Validates that --attach requires --auto flag.
- * Attachments can only be used in autonomous mode.
+ * Validates that --attach requires --auto or --json-io flag.
+ * Attachments can be used in autonomous mode or json-io mode (for Agent Manager).
  */
-export function validateAttachRequiresAuto(options: { attach?: string[]; auto?: boolean }): AttachmentValidationResult {
+export function validateAttachRequiresAuto(options: {
+	attach?: string[]
+	auto?: boolean
+	jsonIo?: boolean
+}): AttachmentValidationResult {
 	const attachments = options.attach || []
 	if (attachments.length > 0) {
-		if (!options.auto) {
+		if (!options.auto && !options.jsonIo) {
 			return {
 				valid: false,
-				error: "Error: --attach option requires --auto flag",
+				error: "Error: --attach option requires --auto or --json-io flag",
 			}
 		}
 	}

+ 1 - 1
package.json

@@ -2,7 +2,7 @@
 	"name": "kilo-code",
 	"packageManager": "[email protected]",
 	"engines": {
-		"node": "20.19.2"
+		"node": "20.20.0"
 	},
 	"scripts": {
 		"preinstall": "node scripts/bootstrap.mjs",

+ 7 - 0
packages/core-schemas/CHANGELOG.md

@@ -0,0 +1,7 @@
+# @kilocode/core-schemas
+
+## 0.0.1
+
+### Patch Changes
+
+- [#5107](https://github.com/Kilo-Org/kilocode/pull/5107) [`b2e2630`](https://github.com/Kilo-Org/kilocode/commit/b2e26304e562e516383fbf95a3fdc668d88e1487) Thanks [@marius-kilocode](https://github.com/marius-kilocode)! - Upgrade to Zod v4 for consistency with CLI package

+ 2 - 2
packages/core-schemas/package.json

@@ -1,6 +1,6 @@
 {
 	"name": "@kilocode/core-schemas",
-	"version": "0.0.0",
+	"version": "0.0.1",
 	"type": "module",
 	"main": "./dist/index.cjs",
 	"exports": {
@@ -21,7 +21,7 @@
 		"clean": "rimraf dist .turbo"
 	},
 	"dependencies": {
-		"zod": "^3.25.61"
+		"zod": "^4.3.5"
 	},
 	"peerDependencies": {
 		"@roo-code/types": "workspace:^"

+ 1 - 0
packages/core-schemas/src/agent-manager/types.ts

@@ -81,6 +81,7 @@ export const startSessionMessageSchema = z.object({
 	model: z.string().optional(), // Model ID to use for this session
 	versions: z.number().optional(), // Number of versions for multi-version mode
 	labels: z.array(z.string()).optional(), // Labels for multi-version sessions
+	images: z.array(z.string()).optional(), // Image data URLs to include with the prompt
 })
 
 export const agentManagerMessageSchema = z.discriminatedUnion("type", [

+ 2 - 2
packages/core-schemas/src/auth/kilocode.ts

@@ -33,9 +33,9 @@ export const pollingOptionsSchema = z.object({
 	/** Maximum number of attempts before timeout */
 	maxAttempts: z.number(),
 	/** Function to execute on each poll */
-	pollFn: z.function().args().returns(z.promise(z.unknown())),
+	pollFn: z.custom<() => Promise<unknown>>((val) => typeof val === "function"),
 	/** Optional callback for progress updates */
-	onProgress: z.function().args(z.number(), z.number()).returns(z.void()).optional(),
+	onProgress: z.custom<(current: number, total: number) => void>((val) => typeof val === "function").optional(),
 })
 
 /**

+ 1 - 1
packages/core-schemas/src/config/cli-config.ts

@@ -20,7 +20,7 @@ export const cliConfigSchema = z.object({
 	providers: z.array(providerConfigSchema),
 	autoApproval: autoApprovalConfigSchema.optional(),
 	theme: themeIdSchema.optional(),
-	customThemes: z.record(themeSchema).optional(),
+	customThemes: z.record(z.string(), themeSchema).optional(),
 	maxConcurrentFileReads: z.number().min(1).default(DEFAULT_MAX_CONCURRENT_FILE_READS).optional(),
 })
 

+ 1 - 1
packages/core-schemas/src/config/provider.ts

@@ -48,7 +48,7 @@ export const openAIProviderSchema = baseProviderSchema.extend({
 	openAiUseAzure: z.boolean().optional(),
 	azureApiVersion: z.string().optional(),
 	openAiStreamingEnabled: z.boolean().optional(),
-	openAiHeaders: z.record(z.string()).optional(),
+	openAiHeaders: z.record(z.string(), z.string()).optional(),
 })
 
 // OpenRouter provider

+ 2 - 2
packages/core-schemas/src/mcp/server.ts

@@ -6,7 +6,7 @@ import { z } from "zod"
 export const mcpToolSchema = z.object({
 	name: z.string(),
 	description: z.string().optional(),
-	inputSchema: z.record(z.unknown()).optional(),
+	inputSchema: z.record(z.string(), z.unknown()).optional(),
 })
 
 /**
@@ -29,7 +29,7 @@ export const mcpServerStatusSchema = z.enum(["connected", "connecting", "disconn
  */
 export const mcpServerSchema = z.object({
 	name: z.string(),
-	config: z.record(z.unknown()),
+	config: z.record(z.string(), z.unknown()),
 	status: mcpServerStatusSchema,
 	tools: z.array(mcpToolSchema).optional(),
 	resources: z.array(mcpResourceSchema).optional(),

+ 2 - 1
packages/core-schemas/src/messages/extension.ts

@@ -6,6 +6,7 @@ import { z } from "zod"
 export const organizationAllowListSchema = z.object({
 	allowAll: z.boolean(),
 	providers: z.record(
+		z.string(),
 		z.object({
 			allowAll: z.boolean(),
 			models: z.array(z.string()).optional(),
@@ -23,7 +24,7 @@ export const extensionMessageSchema = z.object({
 	state: z.unknown().optional(), // ExtensionState
 	images: z.array(z.string()).optional(),
 	chatMessages: z.array(z.unknown()).optional(), // ExtensionChatMessage[]
-	values: z.record(z.unknown()).optional(),
+	values: z.record(z.string(), z.unknown()).optional(),
 })
 
 /**

+ 1 - 1
packages/evals/README.md

@@ -81,7 +81,7 @@ cd packages/evals && ./scripts/setup.sh
 The setup script does the following:
 
 - Installs development tools: Homebrew, asdf, GitHub CLI, pnpm
-- Installs programming languages: Node.js 20.19.2, Python 3.13.2, Go 1.24.2, Rust 1.85.1, Java 17
+- Installs programming languages: Node.js 20.20.0, Python 3.13.2, Go 1.24.2, Rust 1.85.1, Java 17
 - Sets up VS Code with required extensions
 - Configures Docker services (PostgreSQL, Redis)
 - Clones/updates the evals repository

部分文件因文件數量過多而無法顯示