Compare commits

..

No commits in common. "main" and "cli-v0.0.53" have entirely different histories.

2191 changed files with 120448 additions and 95917 deletions

View file

@ -1,9 +1,9 @@
const getReleaseLine = async (changeset) => { const getReleaseLine = async (changeset) => {
const lines = changeset.summary const [firstLine] = changeset.summary
.split("\n") .split("\n")
.map((l) => l.trim()) .map((l) => l.trim())
.filter(Boolean) .filter(Boolean)
return lines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n") return `- ${firstLine}`
} }
const getDependencyReleaseLine = async () => { const getDependencyReleaseLine = async () => {

View file

@ -1,15 +0,0 @@
---
"roo-cline": minor
---
- Remove: Roo Code Cloud and eval infrastructure from the extension, CLI, workflows, and package surfaces so the release is focused on the standalone extension (PR #12328 by @mrubens)
- Remove: All telemetry collection and analytics plumbing across the extension, website, shared types, provider flows, and related tests (PR #12324 by @mrubens)
- Remove: MDM and organization membership enforcement, including host wiring, webview state, user-facing messages, and locale strings (PR #12323 by @mrubens)
- Remove: The MCP marketplace, marketplace services, webview marketplace UI, package contributions, and related localized copy (PR #12326 by @mrubens)
- Update: Extension-facing support, diagnostics, and announcement content for the final Roo Code release, including GitHub help paths and links to Roomote, ZooCode, and Cline (PR #12341 by @brunobergher)
- Add: A cleaned docs app with GitHub Pages deployment support (PR #12344 by @brunobergher)
- Fix: Configure the docs GitHub Pages base URL so deployed assets and canonical paths load correctly under the repository Pages path (PR #12370 by @mrubens)
- Update: Point docs links in the root README, localized READMEs, and web app copy to the current GitHub Pages docs URL (PR #12371 by @mrubens)
- Remove: Stale `roocode.github.io` docs references, including the old CNAME and outdated docs README and robots.txt URLs (PR #12372 by @mrubens)
- Update: The website to focus almost entirely on the Roo Code extension and remove cloud, team, enterprise, provider, pricing, Slack, and Linear product pages (PR #12180 by @brunobergher)
- Remove: Contributor, community, social channel, and tutorial references from README files, docs, website copy, issue templates, and workflows (PR #12347 by @brunobergher)

View file

@ -1,6 +0,0 @@
POSTHOG_API_KEY=key-goes-here
# Roo Code Cloud / Local Development
CLERK_BASE_URL=https://epic-chamois-85.clerk.accounts.dev
ROO_CODE_API_URL=http://localhost:3000
ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy/v1

View file

@ -64,7 +64,7 @@ body:
attributes: attributes:
value: | value: |
--- ---
Optional: You can stop here if you're just proposing the improvement. Optional (for contributors): You can stop here if you're just proposing the improvement.
- type: textarea - type: textarea
id: acceptance-criteria id: acceptance-criteria

75
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,75 @@
<!--
Thank you for contributing to Roo Code!
Before submitting your PR, please ensure:
- It's linked to an approved GitHub Issue.
- You've reviewed our [Contributing Guidelines](../CONTRIBUTING.md).
-->
### Related GitHub Issue
<!-- Every PR MUST be linked to an approved issue. -->
Closes: # <!-- Replace with the issue number, e.g., Closes: #123 -->
### Roo Code Task Context (Optional)
<!--
If you used Roo Code to help create this PR, you can share public task links here.
This helps reviewers understand your development process and provides additional context.
Example: https://app.roocode.com/share/task-id
-->
### Description
<!--
Briefly summarize the changes in this PR and how they address the linked issue.
The issue should cover the "what" and "why"; this section should focus on:
- The "how": key implementation details, design choices, or trade-offs made.
- Anything specific reviewers should pay attention to in this PR.
-->
### Test Procedure
<!--
Detail the steps to test your changes. This helps reviewers verify your work.
- How did you test this specific implementation? (e.g., unit tests, manual testing steps)
- How can reviewers reproduce your tests or verify the fix/feature?
- Include relevant testing environment details if applicable.
-->
### Pre-Submission Checklist
<!-- Go through this checklist before marking your PR as ready for review. -->
- [ ] **Issue Linked**: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above).
- [ ] **Scope**: My changes are focused on the linked issue (one major feature/fix per PR).
- [ ] **Self-Review**: I have performed a thorough self-review of my code.
- [ ] **Testing**: New and/or updated tests have been added to cover my changes (if applicable).
- [ ] **Documentation Impact**: I have considered if my changes require documentation updates (see "Documentation Updates" section below).
- [ ] **Contribution Guidelines**: I have read and agree to the [Contributor Guidelines](/CONTRIBUTING.md).
### Screenshots / Videos
<!--
For UI changes, please provide before-and-after screenshots or a short video of the *actual results*.
This greatly helps in understanding the visual impact of your changes.
-->
### Documentation Updates
<!--
Does this PR necessitate updates to user-facing documentation?
- [ ] No documentation updates are required.
- [ ] Yes, documentation updates are required. (Please describe what needs to be updated or link to a PR in the docs repository).
-->
### Additional Notes
<!-- Add any other context, questions, or information for reviewers here. -->
### Get in Touch
<!--
Please provide your Discord username for reviewers or maintainers to reach you if they have questions about your PR
-->

View file

@ -58,3 +58,66 @@ jobs:
uses: ./.github/actions/setup-node-pnpm uses: ./.github/actions/setup-node-pnpm
- name: Run unit tests - name: Run unit tests
run: pnpm test run: pnpm test
check-openrouter-api-key:
runs-on: ubuntu-latest
outputs:
exists: ${{ steps.openrouter-api-key-check.outputs.defined }}
steps:
- name: Check if OpenRouter API key exists
id: openrouter-api-key-check
shell: bash
run: |
if [ "${{ secrets.OPENROUTER_API_KEY }}" != '' ]; then
echo "defined=true" >> $GITHUB_OUTPUT;
else
echo "defined=false" >> $GITHUB_OUTPUT;
fi
integration-test:
runs-on: ubuntu-latest
needs: [check-openrouter-api-key]
if: needs.check-openrouter-api-key.outputs.exists == 'true'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Create .env.local file
working-directory: apps/vscode-e2e
run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.local
- name: Set VS Code test version
run: echo "VSCODE_VERSION=1.101.2" >> $GITHUB_ENV
- name: Cache VS Code test runtime
uses: actions/cache@v4
with:
path: apps/vscode-e2e/.vscode-test
key: ${{ runner.os }}-vscode-test-${{ env.VSCODE_VERSION }}
- name: Pre-download VS Code test runtime with retry
working-directory: apps/vscode-e2e
run: |
for attempt in 1 2 3; do
echo "Download attempt $attempt of 3..."
node -e "
const { downloadAndUnzipVSCode } = require('@vscode/test-electron');
downloadAndUnzipVSCode({ version: process.env.VSCODE_VERSION || '1.101.2' })
.then(() => {
console.log('✅ VS Code test runtime downloaded successfully');
process.exit(0);
})
.catch(err => {
console.error('❌ Failed to download VS Code (attempt $attempt):', err);
process.exit(1);
});
" && break || {
if [ $attempt -eq 3 ]; then
echo "All download attempts failed"
exit 1
fi
echo "Retrying in 5 seconds..."
sleep 5
}
done
- name: Run integration tests
working-directory: apps/vscode-e2e
run: xvfb-run -a pnpm test:ci

View file

@ -1,55 +0,0 @@
name: Deploy docs to GitHub Pages
on:
push:
branches:
- main
paths:
- "apps/docs/**"
- ".github/workflows/docs-pages.yml"
- ".github/actions/setup-node-pnpm/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
workflow_dispatch:
concurrency:
group: docs-pages
cancel-in-progress: true
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
with:
install-args: "--frozen-lockfile"
- name: Run type check
run: pnpm --filter @roo-code/docs check-types
- name: Run lint
run: pnpm --filter @roo-code/docs lint
- name: Build docs
run: pnpm --filter @roo-code/docs build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: apps/docs/build
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

74
.github/workflows/evals.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: Evals
on:
pull_request:
types: [labeled]
workflow_dispatch:
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
jobs:
evals:
# Run if triggered manually or if PR has 'evals' label.
if: github.event_name == 'workflow_dispatch' || contains(github.event.label.name, 'evals')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 45
defaults:
run:
working-directory: packages/evals
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create environment
run: |
cat > .env.local << EOF
OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY || 'test-key-for-build' }}
EOF
cat > .env.development << EOF
NODE_ENV=development
DATABASE_URL=postgresql://postgres:password@db:5432/evals_development
REDIS_URL=redis://redis:6379
HOST_EXECUTION_METHOD=docker
EOF
- name: Build image
uses: docker/build-push-action@v6
with:
context: .
file: packages/evals/Dockerfile.runner
tags: evals-runner:latest
cache-from: type=gha
cache-to: type=gha,mode=max
push: false
load: true
- name: Tag image
run: docker tag evals-runner:latest evals-runner
- name: Start containers
run: |
docker compose up -d db redis
timeout 60 bash -c 'until docker compose exec -T db pg_isready -U postgres; do sleep 2; done'
timeout 60 bash -c 'until docker compose exec -T redis redis-cli ping | grep -q PONG; do sleep 2; done'
docker compose run --rm runner sh -c 'nc -z db 5432 && echo "✓ Runner -> Database connection successful"'
docker compose run --rm runner sh -c 'nc -z redis 6379 && echo "✓ Runner -> Redis connection successful"'
docker compose run --rm runner docker ps
- name: Run database migrations
run: docker compose run --rm runner pnpm --filter @roo-code/evals db:migrate
- name: Run evals
run: docker compose run --rm runner pnpm --filter @roo-code/evals cli --ci
- name: Cleanup
if: always()
run: docker compose down -v --remove-orphans

View file

@ -0,0 +1,67 @@
name: Update Contributors # Refresh contrib.rocks image cache
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
refresh-contrib-cache:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Bump cacheBust in all README files
run: |
set -euo pipefail
TS="$(date +%s)"
# Target only the root README.md and localized READMEs under locales/*/README.md
mapfile -t FILES < <(git ls-files README.md 'locales/*/README.md' || true)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "No target README files found." >&2
exit 1
fi
UPDATED=0
for f in "${FILES[@]}"; do
if grep -q 'cacheBust=' "$f"; then
# Use portable sed in GNU environment of ubuntu-latest
sed -i -E "s/cacheBust=[0-9]+/cacheBust=${TS}/g" "$f"
echo "Updated cacheBust in $f"
UPDATED=1
else
echo "Warning: cacheBust parameter not found in $f" >&2
fi
done
if [ "$UPDATED" -eq 0 ]; then
echo "No files were updated. Ensure READMEs embed contrib.rocks with cacheBust param." >&2
exit 1
fi
- name: Detect changes
id: changes
run: |
if git diff --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "docs: update contributors list [skip ci]"
committer: "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"
branch: refresh-contrib-cache
delete-branch: true
title: "Refresh contrib.rocks image cache (all READMEs)"
body: |
Automated refresh of the contrib.rocks image cache by bumping the cacheBust parameter in README.md and locales/*/README.md.
base: main

59
.github/workflows/website-deploy.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Deploy roocode.com
on:
push:
branches:
- main
paths:
- 'apps/web-roo-code/**'
workflow_dispatch:
concurrency:
group: deploy-roocode-com
cancel-in-progress: true
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
jobs:
check-secrets:
runs-on: ubuntu-latest
outputs:
has-vercel-token: ${{ steps.check.outputs.has-vercel-token }}
steps:
- name: Check if VERCEL_TOKEN exists
id: check
run: |
if [ -n "${{ secrets.VERCEL_TOKEN }}" ]; then
echo "has-vercel-token=true" >> $GITHUB_OUTPUT
else
echo "has-vercel-token=false" >> $GITHUB_OUTPUT
fi
deploy:
runs-on: ubuntu-latest
needs: check-secrets
if: ${{ needs.check-secrets.outputs.has-vercel-token == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Run lint
run: pnpm lint
working-directory: apps/web-roo-code
- name: Run type check
run: pnpm check-types
working-directory: apps/web-roo-code
- name: Run build
run: pnpm build
working-directory: apps/web-roo-code
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
run: npx vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

102
.github/workflows/website-preview.yml vendored Normal file
View file

@ -0,0 +1,102 @@
name: Preview roocode.com
on:
push:
branches-ignore:
- main
paths:
- "apps/web-roo-code/**"
pull_request:
paths:
- "apps/web-roo-code/**"
workflow_dispatch:
concurrency:
group: preview-roocode-com-${{ github.ref }}
cancel-in-progress: true
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
jobs:
check-secrets:
runs-on: ubuntu-latest
outputs:
has-vercel-token: ${{ steps.check.outputs.has-vercel-token }}
steps:
- name: Check if VERCEL_TOKEN exists
id: check
run: |
if [ -n "${{ secrets.VERCEL_TOKEN }}" ]; then
echo "has-vercel-token=true" >> $GITHUB_OUTPUT
else
echo "has-vercel-token=false" >> $GITHUB_OUTPUT
fi
preview:
runs-on: ubuntu-latest
needs: check-secrets
if: ${{ needs.check-secrets.outputs.has-vercel-token == 'true' }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js and pnpm
uses: ./.github/actions/setup-node-pnpm
- name: Run lint
run: pnpm lint
working-directory: apps/web-roo-code
- name: Run type check
run: pnpm check-types
working-directory: apps/web-roo-code
- name: Run build
run: pnpm build
working-directory: apps/web-roo-code
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel Environment Information
run: npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
run: npx vercel build --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
id: deploy
run: |
DEPLOYMENT_URL=$(npx vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
echo "deployment_url=$DEPLOYMENT_URL" >> $GITHUB_OUTPUT
echo "Preview deployed to: $DEPLOYMENT_URL"
- name: Comment PR with preview link
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const deploymentUrl = '${{ steps.deploy.outputs.deployment_url }}';
const commentIdentifier = '<!-- roo-preview-comment -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.find(comment =>
comment.body.includes(commentIdentifier)
);
const comment = commentIdentifier + '\n🚀 **Preview deployed!**\n\nYour changes have been deployed to Vercel:\n\n**Preview URL:** ' + deploymentUrl + '\n\nThis preview will be updated automatically when you push new commits to this PR.';
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: comment
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}

View file

@ -1,72 +0,0 @@
---
description: "Resolve merge conflicts intelligently using git history analysis"
argument-hint: "#PR-number"
mode: merge-resolver
---
Resolve merge conflicts for a specific pull request by analyzing git history, commit messages, and code changes to make intelligent resolution decisions.
## Quick Start
1. **Provide a PR number** (e.g., `#123` or just `123`)
2. The workflow will automatically:
- Fetch PR information (title, description, branches)
- Checkout the PR branch
- Rebase onto the target branch to reveal conflicts
- Analyze and resolve conflicts using git history
## Workflow Steps
### 1. Initialize PR Resolution
```bash
# Fetch PR info
gh pr view [PR_NUMBER] --json title,body,headRefName,baseRefName
# Checkout and rebase
gh pr checkout [PR_NUMBER] --force
git fetch origin main
GIT_EDITOR=true git rebase origin/main
```
### 2. Identify Conflicts
```bash
git status --porcelain | grep "^UU"
```
### 3. Analyze Each Conflict
For each conflicted file:
- Read the conflict markers
- Run `git blame` on conflicting sections
- Fetch commit messages for context
- Determine the intent behind each change
### 4. Apply Resolution Strategy
Based on the analysis:
- **Bugfixes** generally take precedence over features
- **Recent changes** are often more relevant (unless older is a security fix)
- **Combine** non-conflicting changes when possible
- **Preserve** test updates alongside code changes
### 5. Complete Resolution
```bash
git add [resolved-files]
GIT_EDITOR=true git rebase --continue
```
## Key Guidelines
- Always escape conflict markers with `\` when using `apply_diff`
- Document resolution decisions in the summary
- Verify no syntax errors after resolution
- Preserve valuable changes from both sides when possible
## Examples
- `/roo-resolve-conflicts #123` - Resolve conflicts for PR #123
- `/roo-resolve-conflicts 456` - Resolve conflicts for PR #456

View file

@ -1,50 +0,0 @@
---
description: "Translate and localize strings in the Roo Code extension"
argument-hint: "[language-code or 'all'] [string-key or file-path]"
mode: translate
---
Perform translation and localization tasks for the Roo Code extension. This command activates the translation workflow with comprehensive i18n guidelines.
## Quick Start
1. **Identify the translation scope:**
- If a specific language code is provided (e.g., `de`, `zh-CN`), focus on that language
- If `all` is specified, translate to all supported languages
- If a string key is provided, locate and translate that specific string
- If a file path is provided, work with that translation file
2. **Supported languages:** ca, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW
3. **Translation locations:**
- Core Extension: `src/i18n/locales/`
- WebView UI: `webview-ui/src/i18n/locales/`
## Workflow
1. If adding new strings:
- Add the English string first
- Ask for confirmation before translating to other languages
- Use `apply_diff` for efficient file updates
2. If updating existing strings:
- Identify all affected language files
- Update English first, then propagate changes
3. Validate your changes:
```bash
node scripts/find-missing-translations.js
```
## Key Guidelines
- Use informal speech (e.g., "du" not "Sie" in German)
- Keep technical terms like "token", "Prompt" in English
- Preserve all `{{variable}}` placeholders exactly
- Use `apply_diff` instead of `write_to_file` for existing files
## Examples
- `/roo-translate de` - Focus on German translations
- `/roo-translate all welcome.title` - Translate a specific key to all languages
- `/roo-translate zh-CN src/i18n/locales/zh-CN/core.json` - Work on specific file

View file

@ -1,15 +0,0 @@
# Roo Code Translation Guidance
This file contains brand voice, tone, and word choice guidelines for Roo Code translations.
## Brand Voice
<!-- Add brand voice guidelines here -->
## Tone
<!-- Add tone guidelines here -->
## Word Choice
<!-- Add word choice preferences here -->

View file

@ -1,256 +0,0 @@
---
name: roo-conflict-resolution
description: Provides comprehensive guidelines for resolving merge conflicts intelligently using git history and commit context. Use when tasks involve merge conflicts, rebasing, PR conflicts, or git conflict resolution. This skill analyzes commit messages, git blame, and code intent to make intelligent resolution decisions.
---
# Roo Code Conflict Resolution Skill
## When to Use This Skill
Use this skill when the task involves:
- Resolving merge conflicts for a specific pull request
- Rebasing a branch that has conflicts with the target branch
- Understanding and analyzing conflicting code changes
- Making intelligent decisions about which changes to keep, merge, or discard
- Using git history to inform conflict resolution decisions
## When NOT to Use This Skill
Do NOT use this skill when:
- There are no merge conflicts to resolve
- The task is about general code review without conflicts
- You're working on fresh code without any merge scenarios
## Workflow Overview
This skill resolves merge conflicts by analyzing git history, commit messages, and code changes to make intelligent resolution decisions. Given a PR number (e.g., "#123"), it handles the entire conflict resolution process.
## Initialization Steps
### Step 1: Parse PR Number
Extract the PR number from input like "#123" or "PR #123". Validate that a PR number was provided.
### Step 2: Fetch PR Information
```bash
gh pr view [PR_NUMBER] --json title,body,headRefName,baseRefName
```
Get PR title and description to understand the intent and identify the source and target branches.
### Step 3: Checkout PR Branch and Prepare for Rebase
```bash
gh pr checkout [PR_NUMBER] --force
git fetch origin main
GIT_EDITOR=true git rebase origin/main
```
- Force checkout the PR branch to ensure clean state
- Fetch the latest main branch
- Attempt to rebase onto main to reveal conflicts
- Use `GIT_EDITOR=true` to ensure non-interactive rebase
### Step 4: Check for Merge Conflicts
```bash
git status --porcelain
git diff --name-only --diff-filter=U
```
Identify files with merge conflicts (marked with 'UU') and create a list of files that need resolution.
## Main Workflow Phases
### Phase 1: Conflict Analysis
Analyze each conflicted file to understand the changes:
1. Read the conflicted file to identify conflict markers
2. Extract the conflicting sections between `<<<<<<<` and `>>>>>>>`
3. Run git blame on both sides of the conflict
4. Fetch commit messages and diffs for relevant commits
5. Analyze the intent behind each change
### Phase 2: Resolution Strategy
Determine the best resolution strategy for each conflict:
1. Categorize changes by intent (bugfix, feature, refactor, etc.)
2. Evaluate recency and relevance of changes
3. Check for structural overlap vs formatting differences
4. Identify if changes can be combined or if one should override
5. Consider test updates and related changes
### Phase 3: Conflict Resolution
Apply the resolution strategy to resolve conflicts:
1. For each conflict, apply the chosen resolution
2. Ensure proper escaping of conflict markers in diffs
3. Validate that resolved code is syntactically correct
4. Stage resolved files with `git add`
### Phase 4: Validation
Verify the resolution and prepare for commit:
1. Run `git status` to confirm all conflicts are resolved
2. Check for any compilation or syntax errors
3. Review the final diff to ensure sensible resolutions
4. Prepare a summary of resolution decisions
## Git Commands Reference
| Command | Purpose |
|---------|---------|
| `gh pr checkout [PR_NUMBER] --force` | Force checkout the PR branch |
| `git fetch origin main` | Get the latest main branch |
| `GIT_EDITOR=true git rebase origin/main` | Rebase current branch onto main (non-interactive) |
| `git blame -L [start],[end] [commit] -- [file]` | Get commit information for specific lines |
| `git show --format="%H%n%an%n%ae%n%ad%n%s%n%b" --no-patch [sha]` | Get commit metadata |
| `git show [sha] -- [file]` | Get the actual changes made in a commit |
| `git ls-files -u` | List unmerged files with stage information |
| `GIT_EDITOR=true git rebase --continue` | Continue rebase after resolving conflicts |
## Best Practices
### Intent-Based Resolution (High Priority)
Always prioritize understanding the intent behind changes rather than just looking at the code differences. Commit messages, PR descriptions, and issue references provide crucial context.
**Example:** When there's a conflict between a bugfix and a refactor, apply the bugfix logic within the refactored structure rather than simply choosing one side.
### Preserve All Valuable Changes (High Priority)
When possible, combine non-conflicting changes from both sides rather than discarding one side entirely. Both sides of a conflict often contain valuable changes that can coexist if properly integrated.
### Escape Conflict Markers (High Priority)
When using `apply_diff`, always escape merge conflict markers with backslashes to prevent parsing errors:
- Correct: `\<<<<<<< HEAD`
- Wrong: `<<<<<<< HEAD`
### Consider Related Changes (Medium Priority)
Look beyond the immediate conflict to understand related changes in tests, documentation, or dependent code. A change might seem isolated but could be part of a larger feature or fix.
## Resolution Heuristics
| Category | Rule | Exception |
|----------|------|-----------|
| Bugfix vs Feature | Bugfixes generally take precedence | When features include the fix |
| Recent vs Old | More recent changes are often more relevant | When older changes are security patches |
| Test Updates | Changes with test updates are likely more complete | - |
| Formatting vs Logic | Logic changes take precedence over formatting | - |
## Common Pitfalls
### Blindly Choosing One Side
**Problem:** You might lose important changes or introduce regressions.
**Solution:** Always analyze both sides using git blame and commit history.
### Ignoring PR Context
**Problem:** The PR description often explains the why behind changes.
**Solution:** Always fetch and read the PR information before resolving.
### Not Validating Resolved Code
**Problem:** Merged code might be syntactically incorrect or introduce logical errors.
**Solution:** Always check for syntax errors and review the final diff.
### Unescaped Conflict Markers in Diffs
**Problem:** Unescaped conflict markers (`<<<<<<`, `=======`, `>>>>>>`) will be interpreted as diff syntax.
**Solution:** Always escape with backslash (`\`) when they appear in content.
## Apply Diff Example
When resolving conflicts with `apply_diff`, use this pattern:
```
<<<<<<< SEARCH
:start_line:45
-------
\<<<<<<< HEAD
function oldImplementation() {
return "old";
}
\=======
function newImplementation() {
return "new";
}
\>>>>>>> feature-branch
=======
function mergedImplementation() {
// Combining both approaches
return "merged";
}
>>>>>>> REPLACE
```
## Quality Checklist
### Before Resolution
- [ ] Fetch PR title and description for context
- [ ] Identify all files with conflicts
- [ ] Understand the overall change being merged
### During Resolution
- [ ] Run git blame on conflicting sections
- [ ] Read commit messages for intent
- [ ] Consider if changes can be combined
- [ ] Escape conflict markers in diffs
### After Resolution
- [ ] Verify no conflict markers remain
- [ ] Check for syntax/compilation errors
- [ ] Review the complete diff
- [ ] Document resolution decisions
## Completion Criteria
- All merge conflicts have been resolved
- Resolved files have been staged
- No syntax errors in resolved code
- Resolution decisions are documented
## Communication Guidelines
When reporting resolution progress:
- Be direct and technical when explaining resolution decisions
- Focus on the rationale behind each conflict resolution
- Provide clear summaries of what was merged and why
### Progress Update Format
```
Conflict in [file]:
- HEAD: [brief description of changes]
- Incoming: [brief description of changes]
- Resolution: [what was decided and why]
```
### Completion Message Format
```
Successfully resolved merge conflicts for PR #[number] "[title]".
Resolution Summary:
- [file1]: [brief description of resolution]
- [file2]: [brief description of resolution]
[Key decision explanation if applicable]
All conflicts have been resolved and files have been staged for commit.
```

View file

@ -1,151 +0,0 @@
---
name: roo-translation
description: Provides comprehensive guidelines for translating and localizing Roo Code extension strings. Use when tasks involve i18n, translation, localization, adding new languages, or updating existing translation files. This skill covers both core extension (src/i18n/locales/) and WebView UI (webview-ui/src/i18n/locales/) localization.
---
# Roo Code Translation Skill
## When to Use This Skill
Use this skill when the task involves:
- Adding new translatable strings to the Roo Code extension
- Translating existing strings to new languages
- Updating or fixing translations in existing language files
- Understanding i18n patterns used in the codebase
- Working with localization files in either core extension or WebView UI
## When NOT to Use This Skill
Do NOT use this skill when:
- Working on non-translation code changes
- The task doesn't involve i18n or localization
- You're only reading translation files for reference without modifying them
## Supported Languages and Locations
Localize all strings into the following locale files: ca, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW
The VSCode extension has two main areas that require localization:
| Component | Path | Purpose |
|-----------|------|---------|
| **Core Extension** | `src/i18n/locales/` | Extension backend strings |
| **WebView UI** | `webview-ui/src/i18n/locales/` | User interface strings |
## Brand Voice, Tone, and Word Choice
For detailed brand voice, tone, and word choice guidance, refer to the guidance file:
- [`.roo/guidance/roo-translator.md`](../../guidance/roo-translator.md)
This guidance file is loaded at runtime and should be consulted for the latest brand and style standards.
## Voice, Style and Tone Guidelines
- Always use informal speech (e.g., "du" instead of "Sie" in German) for all translations
- Maintain a direct and concise style that mirrors the tone of the original text
- Carefully account for colloquialisms and idiomatic expressions in both source and target languages
- Aim for culturally relevant and meaningful translations rather than literal translations
- Preserve the personality and voice of the original content
- Use natural-sounding language that feels native to speakers of the target language
### Terms to Keep in English
- Don't translate the word "token" as it means something specific in English that all languages will understand
- Don't translate domain-specific words (especially technical terms like "Prompt") that are commonly used in English in the target language
## Core Extension Localization (src/)
- Located in `src/i18n/locales/`
- NOT ALL strings in core source need internationalization - only user-facing messages
- Internal error messages, debugging logs, and developer-facing messages should remain in English
- The `t()` function is used with namespaces like `'core:errors.missingToolParameter'`
- Be careful when modifying interpolation variables; they must remain consistent across all translations
- Some strings in `formatResponse.ts` are intentionally not internationalized since they're internal
- When updating strings in `core.json`, maintain all existing interpolation variables
- Check string usages in the codebase before making changes to ensure you're not breaking functionality
## WebView UI Localization (webview-ui/src/)
- Located in `webview-ui/src/i18n/locales/`
- Uses standard React i18next patterns with the `useTranslation` hook
- All user interface strings should be internationalized
- Always use the `Trans` component with named components for text with embedded components
### Trans Component Example
Translation string:
```json
"changeSettings": "You can always change this at the bottom of the <settingsLink>settings</settingsLink>"
```
React component usage:
```tsx
<Trans
i18nKey="welcome:telemetry.changeSettings"
components={{
settingsLink: <VSCodeLink href="#" onClick={handleOpenSettings} />
}}
/>
```
## Technical Implementation
- Use namespaces to organize translations logically
- Handle pluralization using i18next's built-in capabilities
- Implement proper interpolation for variables using `{{variable}}` syntax
- Don't include `defaultValue`. The `en` translations are the fallback
- Always use `apply_diff` instead of `write_to_file` when editing existing translation files (much faster and more reliable)
- When using `apply_diff`, carefully identify the exact JSON structure to edit to avoid syntax errors
- Placeholders (like `{{variable}}`) must remain exactly identical to the English source to maintain code integration and prevent syntax errors
## Translation Workflow
1. First add or modify English strings, then ask for confirmation before translating to all other languages
2. Use this process for each localization task:
1. Identify where the string appears in the UI/codebase
2. Understand the context and purpose of the string
3. Update English translation first
4. Use the `search_files` tool to find JSON keys that are near new keys in English translations but do not yet exist in the other language files for `apply_diff` SEARCH context
5. Create appropriate translations for all other supported languages utilizing the `search_files` result using `apply_diff` without reading every file
6. Do not output the translated text into the chat, just modify the files
7. Validate your changes with the missing translations script
3. Flag or comment if an English source string is incomplete ("please see this...") to avoid truncated or unclear translations
4. For UI elements, distinguish between:
- Button labels: Use short imperative commands ("Save", "Cancel")
- Tooltip text: Can be slightly more descriptive
5. Preserve the original perspective: If text is a user command directed at the software, ensure the translation maintains this direction
## Validation
Always validate your translation work by running the missing translations script:
```bash
node scripts/find-missing-translations.js
```
Address any missing translations identified by the script to ensure complete coverage across all locales.
## Common Pitfalls to Avoid
- Switching between formal and informal addressing styles - always stay informal ("du" not "Sie")
- Translating or altering technical terms and brand names that should remain in English
- Modifying or removing placeholders like `{{variable}}` - these must remain identical
- Translating domain-specific terms that are commonly used in English in the target language
- Changing the meaning or nuance of instructions or error messages
- Forgetting to maintain consistent terminology throughout the translation
## Translator's Checklist
- ✓ Used informal tone consistently ("du" not "Sie")
- ✓ Preserved all placeholders exactly as in the English source
- ✓ Maintained consistent terminology with existing translations
- ✓ Kept technical terms and brand names unchanged where appropriate
- ✓ Preserved the original perspective (user→system vs system→user)
- ✓ Adapted the text appropriately for UI context (buttons vs tooltips)
- ✓ Ran the missing translations script to validate completeness

View file

@ -1,2 +1 @@
pnpm 10.8.1
nodejs 20.19.2 nodejs 20.19.2

View file

@ -1,200 +1,83 @@
# Roo Code Changelog # Roo Code Changelog
## 3.53.0 ## [3.47.3] - 2026-02-06
### Minor Changes - Remove "Enable URL context" and "Enable Grounding with Google search" checkboxes that are no longer needed (PR #11253 by @roomote)
- Revert refactor that appended environment details into existing blocks, restoring original behavior (PR #11256 by @mrubens)
- Revert removal of stripAppendedEnvironmentDetails and helpers, restoring necessary utility functions (PR #11255 by @mrubens)
- **The Roo Code plugin is not going away.** You may have seen the [recent announcement](https://x.com/mattrubens/status/2046636598859559114) that Roo Code hit 3 million installs and the original team is going all-in on Roomote. We know that news was hard for a lot of you. This plugin means a lot to us and to you, and we hear you. The good news: a community team has stepped up to carry Roo Code forward, and we're working with them on an official handoff so the plugin you rely on keeps getting maintained and improved. ## [3.47.2] - 2026-02-05
- Add GPT-5.5 support via the OpenAI Codex provider (PR #12170 by @hannesrudolph)
- Add Claude Opus 4.7 support on Vertex AI (#12134 by @saneroen, PR #12135 by @saneroen)
- Add previous checkpoint navigation controls and i18n in chat (#12138 by @saneroen, PR #12139 by @saneroen)
- Add Roomote banner (PR #12119 by @brunobergher)
- Redesign Roomote announcement banner with violet branding on the web (PR #12161 by @roomote-v0)
- Add sunsetting Roo Code blog post (PR #12160 by @roomote-v0)
## 3.52.1 - Add support for .agents/skills directory (PR #11181 by @roomote)
- Fix: Restore Gemini thought signature round-tripping after AI SDK migration (PR #11237 by @hannesrudolph)
- Fix: Capture and round-trip thinking signature for Bedrock Claude (PR #11238 by @hannesrudolph)
### Patch Changes ## [3.47.1] - 2026-02-05
- Add correct JSON schema for `.roomodes` configuration files (#11790 by @algorhythm85, PR #11791 by @app/roomote-v0) - Fix: Correct Bedrock model ID for Claude Opus 4.6, resolving model selection issues for Bedrock users (#11231 by @cogwirrel, PR #11232 by @roomote)
- Remove the hiring announcement from the VS Code extension UI (PR #12108 by @app/roomote-v0) - Fix: Guard against empty-string baseURL in provider constructors, preventing connection errors when baseURL is accidentally set to empty string (PR #11233 by @hannesrudolph)
- Chore: Remove unused stripAppendedEnvironmentDetails and helpers to clean up codebase (#11228 by @hannesrudolph, PR #11226 by @hannesrudolph)
## 3.52.0 ## [3.47.0] - 2026-02-05
### Minor Changes ![3.47.0 Release - Claude Opus 4.6 & GPT-5.3-Codex](/releases/3.47.0-release.png)
- Add Poe as an AI provider so users can access Poe models directly in Roo Code (PR #12015 by @kamilio) - Add Claude Opus 4.6 support across all providers (#11223 by @hannesrudolph, PR #11224 by @hannesrudolph and @PeterDaveHello)
- Improve the xAI provider by migrating it to the Responses API with reusable transform utilities (#11961 by @carlesso, PR #11962 by @carlesso) - Add GPT-5.3-Codex model to OpenAI - ChatGPT provider (PR #11225 by @roomote)
- Fix MiniMax model listings and context window handling for more reliable configuration (#11999 by @Rexarrior, PR #12069 by @Rexarrior) - Migrate Gemini and Vertex providers to AI SDK for improved reliability and consistency (PR #11180 by @daniel-lxs)
- Add xAI Grok-4.20 models and update the default xAI model selection (#11955 by @carlesso, PR #11956 by @carlesso) - Improve Skills and Slash Commands settings UI with multi-mode support (PR #11157 by @brunobergher)
- Add OpenAI GPT-5.4 mini and nano models to expand the available OpenAI model lineup (PR #11946 by @PeterDaveHello) - Add support for AGENTS.local.md personal override files (PR #11183 by @roomote)
- Chore: include the automated version bump PR from the previous release cycle for complete release accounting (PR #11892 by @app/github-actions) - Add Kimi K2.5 model to Fireworks provider (PR #11177 by @daniel-lxs)
- Improve CLI dev experience and Roo provider API key support (PR #11203 by @cte)
- Fix: Preserve reasoning parts in AI SDK message conversion (#11199 by @hannesrudolph, PR #11217 by @hannesrudolph)
- Refactor: Append environment details into existing blocks for cleaner context (#11200 by @hannesrudolph, PR #11198 by @hannesrudolph)
- Fix: Resolve race condition causing provider switch during CLI mode changes (PR #11205 by @cte)
- Roo Code CLI v0.0.50 (PR #11204 by @cte)
- Chore: Remove dead toolFormat code from getEnvironmentDetails (#11206 by @hannesrudolph, PR #11207 by @roomote)
- Refactor: Simplify docs-extractor mode to focus on raw fact extraction (PR #11129 by @hannesrudolph)
- Revert then re-land AI SDK reasoning fix (PR #11216 by @mrubens, PR #11196 by @hannesrudolph)
### Patch Changes ## [3.46.2] - 2026-02-03
- Add support for OpenAI `gpt-5.4-mini` and `gpt-5.4-nano` models. - Fix: Queue messages during command execution instead of losing them (PR #11140 by @mrubens)
- Fix: Transform tool blocks to text before condensing to prevent context corruption (PR #10975 by @daniel-lxs)
- Fix: Add image content support to MCP tool responses (PR #10874 by @roomote)
- Fix: Remove deprecated text-embedding-004 and migrate code index to gemini-embedding-001 (PR #11038 by @roomote)
- Feat: Use custom Base URL for OpenRouter model list fetch (#11150 by @sebastianlang84, PR #11154 by @roomote)
- Feat: Migrate Mistral provider to AI SDK (PR #11089 by @daniel-lxs)
- Feat: Migrate SambaNova provider to AI SDK (PR #11153 by @roomote)
- Feat: Migrate xAI provider to AI SDK (PR #11158 by @roomote)
- Chore: Remove Feature Request from issue template options (PR #11141 by @roomote)
- Fix: IPC improvements for task cancellation and queued message handling (PR #11162 by @cte)
## 3.51.1 ## [3.46.1] - 2026-01-30
### Patch Changes - Fix: Sanitize tool_use_id in tool_result blocks to match API history, preventing message format errors (PR #11131 by @daniel-lxs)
- Add: Mode dropdown to change skill mode dynamically, allowing more flexible skill configuration (PR #11102 by @SannidhyaSah)
- Add: Import settings option in the initial welcome screen for easier onboarding (#10992 by @emeraldcheshire, PR #10994 by @roomote)
- Chore: Treat extension .env as optional to simplify development setup (PR #11116 by @hannesrudolph)
- Feat: Add Cohere Embed v4 model support for Bedrock and improve credential handling (#11823 by @cscvenkatmadurai, PR #11824 by @cscvenkatmadurai) ## [3.46.0] - 2026-01-30
- Feat: Add Gemini 3.1 Pro customtools model to Vertex AI provider (PR #11857 by @NVolcz)
- Feat: Add gpt-5.4 to ChatGPT Plus/Pro (Codex) model catalog (PR #11876 by @roomote-v0)
## 3.51.0 ![3.46.0 Release - Parallel Processing Power](/releases/3.46.0-release.png)
### Minor Changes - Parallel tool calls enabled by default for improved performance (PR #11031 by @daniel-lxs)
- Codex-inspired read_file refactor introduces indentation mode for extracting complete semantic code blocks without mid-function truncation, ideal when targeting specific lines from search results or errors (#10239 by @pwilkin, PR #10981 by @hannesrudolph)
- Add OpenAI GPT-5.4 and GPT-5.3 Chat Latest model support so Roo Code can use the newest OpenAI chat models (PR #11848 by @PeterDaveHello) - Lossless terminal output with new read_command_output tool allows retrieving full command output from truncated executions with pagination and regex filtering (#10941 by @hannesrudolph, PR #10944 by @hannesrudolph)
- Add support for exposing skills as slash commands with skill fallback execution for faster workflows (PR #11834 by @hannesrudolph) - New skill system replaces fetch_instructions with a dedicated skill tool and built-in skills for create-mcp-server and create-mode, with configurable skill locations and mandatory skill checks (#11062 by @hannesrudolph, PR #11084 by @hannesrudolph)
- Add CLI support for `--create-with-session-id` plus UUID session validation for more controlled session creation (PR #11859 by @cte) - Skills management UI added to settings panel for managing workspace and global skills (#10513 by @SannidhyaSah, PR #10844 by @SannidhyaSah)
- Add support for choosing a specific shell when running terminal commands (PR #11851 by @jr) - AI SDK provider migrations: Moonshot (PR #11063 by @daniel-lxs), DeepSeek (PR #11079 by @daniel-lxs), Cerebras (PR #11086 by @daniel-lxs), Groq (PR #11088 by @daniel-lxs), and Fireworks (PR #11118 by @daniel-lxs) now use the AI SDK for better streaming and tool support
- Feature: Add the `ROO_ACTIVE` environment variable to terminal session settings for safer terminal guardrails (#11864 by @ajjuaire, PR #11862 by @ajjuaire) - Add OpenAI-compatible base provider infrastructure for AI SDK migrations (PR #11063 by @daniel-lxs)
- Improve cloud settings freshness by updating the refresh interval to one hour (PR #11749 by @roomote-v0) - Add AI SDK dependencies and message conversion utilities (PR #11047 by @daniel-lxs)
- Add CLI session resume/history support plus an upgrade command for better long-running workflows (PR #11768 by @cte) - React Compiler integration added to webview-ui for automatic memoization and performance improvements (#9916 by @In-line, PR #9565 by @In-line)
- Add support for images in CLI stdin stream commands (PR #11831 by @cte) - Fix: Include reserved output tokens in task header percentage calculation (PR #11034 by @app/roomote)
- Include `exitCode` in CLI command `tool_result` events for more reliable automation (PR #11820 by @cte) - Fix: Calculate header percentage based on available input space (PR #11054 by @app/roomote)
- Add CLI types to improve development ergonomics and type safety (PR #11781 by @cte) - Fix: Prevent time-travel bug in parallel tool calling (PR #11046 by @daniel-lxs)
- Add CLI integration coverage for stdin stream routing and race-condition invariants (PR #11846 by @cte) - Docs: Clarify read_command_output search param should be omitted when not filtering (PR #11056 by @hannesrudolph)
- Fix the CLI stdin-stream cancel race and add an integration test suite to prevent regressions (PR #11817 by @cte) - Add pnpm serve command for code-server development (PR #10964 by @mrubens)
- Improve CLI stream recovery and add a configurable consecutive mistake limit (PR #11775 by @cte) - Update Next.js to latest version (PR #11108 by @cte)
- Fix CLI streaming deltas, task ID propagation, cancel recovery, and other runtime edge cases (PR #11736 by @cte) - Replace bespoke navigation menu with shadcn navigation menu on website (PR #11117 by @app/roomote)
- Fix CLI task resumption so paused work can reliably continue (PR #11739 by @cte) - Add Linear integration marketing page to website (PR #11028 by @app/roomote)
- Recover from unhandled exceptions in the CLI instead of failing hard (PR #11750 by @cte)
- Scope CLI session and resume flags to the current workspace to avoid cross-workspace confusion (PR #11774 by @cte)
- Fix stdin prompt streaming to forward task configuration correctly (PR #11778 by @daniel-lxs)
- Handle stdin-stream control-flow errors gracefully in the CLI runtime (PR #11811 by @cte)
- Fix stdin stream queued messages and command output streaming in the CLI (PR #11814 by @cte)
- Increase the CLI command execution timeout for long-running commands (PR #11815 by @cte)
- Fix knip checks to keep repository validation green (PR #11819 by @cte)
- Fix CLI upgrade version detection so upgrades resolve the correct target version (PR #11829 by @cte)
- Ignore model-provided timeout values in the CLI runtime to keep command handling consistent (PR #11835 by @cte)
- Fix redundant skill reloading during conversations to reduce duplicate work (PR #11838 by @hannesrudolph)
- Ensure full command output is streamed before the CLI reports completion (PR #11842 by @cte)
- Fix CLI follow-up routing after completion prompts so next actions land in the right place (PR #11844 by @cte)
- Remove the Netflix logo from the homepage (PR #11787 by @roomote-v0)
- Chore: Prepare CLI release v0.1.2 (PR #11737 by @cte)
- Chore: Prepare CLI release v0.1.3 (PR #11740 by @cte)
- Chore: Prepare CLI release v0.1.4 (PR #11751 by @cte)
- Chore: Prepare CLI release v0.1.5 (PR #11772 by @cte)
- Chore: Prepare CLI release v0.1.6 (PR #11780 by @cte)
- Release Roo Code v1.113.0 (PR #11782 by @cte)
- Chore: Prepare CLI release v0.1.7 (PR #11812 by @cte)
- Chore: Prepare CLI release v0.1.8 (PR #11816 by @cte)
- Chore: Prepare CLI release v0.1.9 (PR #11818 by @cte)
- Chore: Prepare CLI release v0.1.10 (PR #11821 by @cte)
- Release Roo Code v1.114.0 (PR #11822 by @cte)
- Chore: Prepare CLI release v0.1.11 (PR #11832 by @cte)
- Release Roo Code v1.115.0 (PR #11833 by @cte)
- Chore: Prepare CLI release v0.1.12 (PR #11836 by @cte)
- Chore: Prepare CLI release v0.1.13 (PR #11837 by @hannesrudolph)
- Chore: Prepare CLI release v0.1.14 (PR #11843 by @cte)
- Chore: Prepare CLI release v0.1.15 (PR #11845 by @cte)
- Chore: Prepare CLI release v0.1.16 (PR #11852 by @cte)
- Chore: Prepare CLI release v0.1.17 (PR #11860 by @cte)
### Patch Changes
- Add OpenAI's GPT-5.3-Chat-Latest model support
- Add OpenAI's GPT-5.3-Codex model support
- Add OpenAI's GPT-5.4 model support
- Add OpenAI's GPT-5.3-Codex model support (PR #11728 by @PeterDaveHello)
- Warm Roo models on CLI startup for faster initial responses (PR #11722 by @cte)
- Fix spelling/grammar and casing inconsistencies (#11478 by @PeterDaveHello, PR #11485 by @PeterDaveHello)
- Fix: Restore Linear integration page (PR #11725 by @roomote)
- Chore: Prepare CLI release v0.1.1 (PR #11723 by @cte)
## [3.50.4] - 2026-02-21
- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote)
## [3.50.3] - 2026-02-20
- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote)
- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m)
## [3.50.2] - 2026-02-20
- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager)
- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote)
- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck)
## [3.50.1] - 2026-02-20
- Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed.
## [3.50.0] - 2026-02-19
- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello)
- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte)
- Prepare CLI v0.1.0 release (PR #11599 by @cte)
- Remove integration tests (PR #11598 by @roomote)
- Changeset version bump (PR #11596 by @github-actions)
## [3.49.0] - 2026-02-19
- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen)
- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20)
- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote)
- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph)
- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote)
## [3.48.1] - 2026-02-18
- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs)
- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello)
- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote)
## [3.48.0] - 2026-02-17
- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello)
- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph)
- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph)
- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs)
- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph)
- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah)
- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah)
- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph)
- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote)
- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs)
- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph)
- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph)
- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph)
- Remove browser use functionality entirely (PR #11392 by @hannesrudolph)
- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph)
- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph)
- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph)
- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph)
- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote)
- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph)
- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs)
- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah)
- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald)
- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink)
- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph)
- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph)
- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink)
- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink)
- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote)
- Fix task resumption in the API module (PR #11369 by @cte)
- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote)
- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs)
- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink)
- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph)
- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs)
- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote)
- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote)
- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote)
- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte)
- Add stdin stream mode for the CLI (PR #11476 by @cte)
- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte)
- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote)
- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte)
- CLI release: v0.0.51 (PR #11274 by @cte)
- CLI release: v0.0.52 (PR #11324 by @cte)
- CLI release: v0.0.53 (PR #11425 by @cte)
- CLI release: v0.0.54 (PR #11477 by @cte)
## [3.45.0] - 2026-01-27 ## [3.45.0] - 2026-01-27
@ -667,7 +550,7 @@
- Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph) - Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph)
- Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote) - Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote)
- Web: Add product pages (PR #9865 by @brunobergher) - Web: Add product pages (PR #9865 by @brunobergher)
- Make eval runs deletable in the web UI (PR #9909 by @mrubens) - Make eval runs deleteable in the web UI (PR #9909 by @mrubens)
- Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote) - Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote)
## [3.36.2] - 2025-12-04 ## [3.36.2] - 2025-12-04
@ -1274,6 +1157,7 @@
- Reposition Add Image button inside ChatTextArea (thanks @roomote!) - Reposition Add Image button inside ChatTextArea (thanks @roomote!)
- Bring back a way to temporarily and globally pause auto-approve without losing your toggle state (thanks @brunobergher!) - Bring back a way to temporarily and globally pause auto-approve without losing your toggle state (thanks @brunobergher!)
- Makes text area buttons appear only when there's text (thanks @brunobergher!) - Makes text area buttons appear only when there's text (thanks @brunobergher!)
- CONTRIBUTING.md tweaks and issue template rewrite (thanks @hannesrudolph!)
- Bump axios from 1.9.0 to 1.12.0 (thanks @dependabot!) - Bump axios from 1.9.0 to 1.12.0 (thanks @dependabot!)
## [3.28.2] - 2025-09-14 ## [3.28.2] - 2025-09-14
@ -1714,7 +1598,7 @@
- Add: Mistral embedding provider (thanks @SannidhyaSah!) - Add: Mistral embedding provider (thanks @SannidhyaSah!)
- Fix: add run parameter to vitest command in rules (thanks @KJ7LNW!) - Fix: add run parameter to vitest command in rules (thanks @KJ7LNW!)
- Update: the max_tokens fallback logic in the sliding window - Update: the max_tokens fallback logic in the sliding window
- Fix: Bedrock and Vertex token counting improvements (thanks @daniel-lxs!) - Fix: Bedrock and Vertext token counting improvements (thanks @daniel-lxs!)
- Add: llama-4-maverick model to Vertex AI provider (thanks @MuriloFP!) - Add: llama-4-maverick model to Vertex AI provider (thanks @MuriloFP!)
- Fix: properly distinguish between user cancellations and API failures - Fix: properly distinguish between user cancellations and API failures
- Fix: add case sensitivity mention to suggested fixes in apply_diff error message - Fix: add case sensitivity mention to suggested fixes in apply_diff error message
@ -1733,6 +1617,7 @@
- Fix Claude model detection by name for API protocol selection (thanks @daniel-lxs!) - Fix Claude model detection by name for API protocol selection (thanks @daniel-lxs!)
- Move marketplace icon from overflow menu to top navigation - Move marketplace icon from overflow menu to top navigation
- Optional setting to prevent completion with open todos - Optional setting to prevent completion with open todos
- Added YouTube to website footer (thanks @thill2323!)
## [3.23.14] - 2025-07-17 ## [3.23.14] - 2025-07-17
@ -2023,7 +1908,7 @@
- Sync BatchDiffApproval styling with BatchFilePermission for UI consistency (thanks @samhvw8!) - Sync BatchDiffApproval styling with BatchFilePermission for UI consistency (thanks @samhvw8!)
- Add max height constraint to MCP execution response for better UX (thanks @samhvw8!) - Add max height constraint to MCP execution response for better UX (thanks @samhvw8!)
- Prevent MCP 'installed' label from being squeezed #4630 (thanks @daniel-lxs!) - Prevent MCP 'installed' label from being squeezed #4630 (thanks @daniel-lxs!)
- Allow a lower context condensing threshold (thanks @SECKainersdorfer!) - Allow a lower context condesning threshold (thanks @SECKainersdorfer!)
- Avoid type system duplication for cleaner codebase (thanks @EamonNerbonne!) - Avoid type system duplication for cleaner codebase (thanks @EamonNerbonne!)
## [3.20.1] - 2025-06-12 ## [3.20.1] - 2025-06-12
@ -2126,6 +2011,7 @@
- Fix bug with context condensing in Amazon Bedrock - Fix bug with context condensing in Amazon Bedrock
- Fix UTF-8 encoding in ExecaTerminalProcess (thanks @mr-ryan-james!) - Fix UTF-8 encoding in ExecaTerminalProcess (thanks @mr-ryan-james!)
- Set sidebar name bugfix (thanks @chrarnoldus!) - Set sidebar name bugfix (thanks @chrarnoldus!)
- Fix link to CONTRIBUTING.md in feature request template (thanks @cannuri!)
- Add task metadata to Unbound and improve caching logic (thanks @pugazhendhi-m!) - Add task metadata to Unbound and improve caching logic (thanks @pugazhendhi-m!)
## [3.19.0] - 2025-05-29 ## [3.19.0] - 2025-05-29
@ -2179,7 +2065,7 @@
## [3.18.2] - 2025-05-23 ## [3.18.2] - 2025-05-23
- Fix vscode-material-icons in the file picker - Fix vscode-material-icons in the filer picker
- Fix global settings export - Fix global settings export
- Respect user-configured terminal integration timeout (thanks @KJ7LNW) - Respect user-configured terminal integration timeout (thanks @KJ7LNW)
- Context condensing enhancements (thanks @SannidhyaSah) - Context condensing enhancements (thanks @SannidhyaSah)
@ -2297,7 +2183,7 @@
- Add vertical tab navigation to the settings (thanks @dlab-anton) - Add vertical tab navigation to the settings (thanks @dlab-anton)
- Add Groq and Chutes API providers (thanks @shariqriazz) - Add Groq and Chutes API providers (thanks @shariqriazz)
- Clickable code references in code block (thanks @KJ7LNW) - Clickable code references in code block (thanks @KJ7LNW)
- Improve accessibility of auto-approve toggles (thanks @Deon588) - Improve accessibility of ato-approve toggles (thanks @Deon588)
- Requesty provider fixes (thanks @dtrugman) - Requesty provider fixes (thanks @dtrugman)
- Fix migration and persistence of per-mode API profiles (thanks @alasano) - Fix migration and persistence of per-mode API profiles (thanks @alasano)
- Fix usage of `path.basename` in the extension webview (thanks @samhvw8) - Fix usage of `path.basename` in the extension webview (thanks @samhvw8)
@ -2359,7 +2245,7 @@
- Fix file mentions for filenames containing spaces - Fix file mentions for filenames containing spaces
- Improve the auto-approve toggle buttons for some high-contrast VSCode themes - Improve the auto-approve toggle buttons for some high-contrast VSCode themes
- Offload expensive count token operations to a web worker (thanks @samhvw8) - Offload expensive count token operations to a web worker (thanks @samhvw8)
- Improve support for multi-root workspaces (thanks @snoyiatk) - Improve support for mult-root workspaces (thanks @snoyiatk)
- Simplify and streamline Roo Code's quick actions - Simplify and streamline Roo Code's quick actions
- Allow Roo Code settings to be imported from the welcome screen (thanks @julionav) - Allow Roo Code settings to be imported from the welcome screen (thanks @julionav)
- Remove unused types (thanks @wkordalski) - Remove unused types (thanks @wkordalski)
@ -2765,7 +2651,7 @@
- Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!) - Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!)
- Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!) - Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!)
- Fix browser system prompt inclusion rules (thanks @cannuri!) - Fix browser system prompt inclusion rules (thanks @cannuri!)
- Publish git tags to GitHub from CI (thanks @pdecat!) - Publish git tags to github from CI (thanks @pdecat!)
- Fixes to OpenAI-style cost calculations (thanks @dtrugman!) - Fixes to OpenAI-style cost calculations (thanks @dtrugman!)
- Fix to allow using an excluded directory as your working directory (thanks @Szpadel!) - Fix to allow using an excluded directory as your working directory (thanks @Szpadel!)
- Kotlin language support in list_code_definition_names tool (thanks @kohii!) - Kotlin language support in list_code_definition_names tool (thanks @kohii!)
@ -2870,7 +2756,7 @@
## [3.7.6] - 2025-02-26 ## [3.7.6] - 2025-02-26
- Handle really long text better in the ChatRow similar to TaskHeader (thanks @joemanley201!) - Handle really long text better in the in the ChatRow similar to TaskHeader (thanks @joemanley201!)
- Support multiple files in drag-and-drop - Support multiple files in drag-and-drop
- Truncate search_file output to avoid crashing the extension - Truncate search_file output to avoid crashing the extension
- Better OpenRouter error handling (no more "Provider Error") - Better OpenRouter error handling (no more "Provider Error")
@ -3093,6 +2979,7 @@
- Ask and Architect modes can now edit markdown files - Ask and Architect modes can now edit markdown files
- Custom modes can now be restricted to specific file patterns (for example, a technical writer who can only edit markdown files 👋) - Custom modes can now be restricted to specific file patterns (for example, a technical writer who can only edit markdown files 👋)
- Support for configuring the Bedrock provider with AWS Profiles - Support for configuring the Bedrock provider with AWS Profiles
- New Roo Code community Discord at https://roocode.com/discord!
## [3.2.8] ## [3.2.8]
@ -3132,6 +3019,8 @@
- Create specialized assistants for any workflow - Create specialized assistants for any workflow
- Just type "Create a new mode for <X>" or visit the Prompts tab in the top menu to get started - Just type "Create a new mode for <X>" or visit the Prompts tab in the top menu to get started
Join us at https://www.reddit.com/r/RooCode to share your custom modes and be part of our next chapter!
## [3.1.7] ## [3.1.7]
- DeepSeek-R1 support (thanks @philipnext!) - DeepSeek-R1 support (thanks @philipnext!)
@ -3179,8 +3068,12 @@
## [3.0.1] ## [3.0.1]
- Fix the reddit link and a small visual glitch in the chat input
## [3.0.0] ## [3.0.0]
- This release adds chat modes! Now you can ask Roo Code questions about system architecture or the codebase without immediately jumping into writing code. You can even assign different API configuration profiles to each mode if you prefer to use different models for thinking vs coding. Would love feedback in the new Roo Code Reddit! https://www.reddit.com/r/RooCode
## [2.2.46] ## [2.2.46]
- Only parse @-mentions in user input (not in files) - Only parse @-mentions in user input (not in files)

90
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,90 @@
<div align="center">
<sub>
<b>English</b> • [Català](locales/ca/CODE_OF_CONDUCT.md) • [Deutsch](locales/de/CODE_OF_CONDUCT.md) • [Español](locales/es/CODE_OF_CONDUCT.md) • [Français](locales/fr/CODE_OF_CONDUCT.md) • [हिंदी](locales/hi/CODE_OF_CONDUCT.md) • [Bahasa Indonesia](locales/id/CODE_OF_CONDUCT.md) • [Italiano](locales/it/CODE_OF_CONDUCT.md) • [日本語](locales/ja/CODE_OF_CONDUCT.md)
</sub>
<sub>
[한국어](locales/ko/CODE_OF_CONDUCT.md) • [Nederlands](locales/nl/CODE_OF_CONDUCT.md) • [Polski](locales/pl/CODE_OF_CONDUCT.md) • [Português (BR)](locales/pt-BR/CODE_OF_CONDUCT.md) • [Русский](locales/ru/CODE_OF_CONDUCT.md) • [Türkçe](locales/tr/CODE_OF_CONDUCT.md) • [Tiếng Việt](locales/vi/CODE_OF_CONDUCT.md) • [简体中文](locales/zh-CN/CODE_OF_CONDUCT.md) • [繁體中文](locales/zh-TW/CODE_OF_CONDUCT.md)
</sub>
</div>
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at support@roocode.com. All complaints
will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from [Cline's version][cline_coc] of the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[cline_coc]: https://github.com/cline/cline/blob/main/CODE_OF_CONDUCT.md
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

141
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,141 @@
<div align="center">
<sub>
<b>English</b> • [Català](locales/ca/CONTRIBUTING.md) • [Deutsch](locales/de/CONTRIBUTING.md) • [Español](locales/es/CONTRIBUTING.md) • [Français](locales/fr/CONTRIBUTING.md) • [हिंदी](locales/hi/CONTRIBUTING.md) • [Bahasa Indonesia](locales/id/CONTRIBUTING.md) • [Italiano](locales/it/CONTRIBUTING.md) • [日本語](locales/ja/CONTRIBUTING.md)
</sub>
<sub>
[한국어](locales/ko/CONTRIBUTING.md) • [Nederlands](locales/nl/CONTRIBUTING.md) • [Polski](locales/pl/CONTRIBUTING.md) • [Português (BR)](locales/pt-BR/CONTRIBUTING.md) • [Русский](locales/ru/CONTRIBUTING.md) • [Türkçe](locales/tr/CONTRIBUTING.md) • [Tiếng Việt](locales/vi/CONTRIBUTING.md) • [简体中文](locales/zh-CN/CONTRIBUTING.md) • [繁體中文](locales/zh-TW/CONTRIBUTING.md)
</sub>
</div>
# Contributing to Roo Code
Roo Code is a community-driven project, and we deeply value every contribution. To streamline collaboration, we operate on an [Issue-First](#issue-first-approach) basis, meaning all [Pull Requests (PRs)](#submitting-a-pull-request) must first be linked to a GitHub Issue. Please review this guide carefully.
## Table of Contents
- [Before You Contribute](#before-you-contribute)
- [Finding & Planning Your Contribution](#finding--planning-your-contribution)
- [Development & Submission Process](#development--submission-process)
- [Legal](#legal)
## Before You Contribute
### 1. Code of Conduct
All contributors must adhere to our [Code of Conduct](./CODE_OF_CONDUCT.md).
### 2. Project Roadmap
Our roadmap guides the project's direction. Align your contributions with these key goals:
### Reliability First
- Ensure diff editing and command execution are consistently reliable.
- Reduce friction points that deter regular usage.
- Guarantee smooth operation across all locales and platforms.
- Expand robust support for a wide variety of AI providers and models.
### Enhanced User Experience
- Streamline the UI/UX for clarity and intuitiveness.
- Continuously improve the workflow to meet the high expectations developers have for daily-use tools.
### Leading on Agent Performance
- Establish comprehensive evaluation benchmarks (evals) to measure real-world productivity.
- Make it easy for everyone to easily run and interpret these evals.
- Ship improvements that demonstrate clear increases in eval scores.
Mention alignment with these areas in your PRs.
### 3. Join the Roo Code Community
- **Primary:** Join our [Discord](https://discord.gg/roocode) and DM **Hannes Rudolph (`hrudolph`)**.
- **Alternative:** Experienced contributors can engage directly via [GitHub Projects](https://github.com/orgs/RooCodeInc/projects/1).
## Finding & Planning Your Contribution
### Types of Contributions
- **Bug Fixes:** Addressing code issues.
- **New Features:** Adding functionality.
- **Documentation:** Improving guides and clarity.
### Issue-First Approach
All contributions start with a GitHub Issue using our skinny templates.
- **Check existing issues**: Search [GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues).
- **Create an issue** using:
- **Enhancements:** "Enhancement Request" template (plain language focused on user benefit).
- **Bugs:** "Bug Report" template (minimal repro + expected vs actual + version).
- **Want to work on it?** Comment "Claiming" on the issue and DM **Hannes Rudolph (`hrudolph`)** on [Discord](https://discord.gg/roocode) to get assigned. Assignment will be confirmed in the thread.
- **PRs must link to the issue.** Unlinked PRs may be closed.
### Deciding What to Work On
- Check the [GitHub Project](https://github.com/orgs/RooCodeInc/projects/1) for "Issue [Unassigned]" issues.
- For docs, visit [Roo Code Docs](https://github.com/RooCodeInc/Roo-Code-Docs).
### Reporting Bugs
- Check for existing reports first.
- Create a new bug using the ["Bug Report" template](https://github.com/RooCodeInc/Roo-Code/issues/new/choose) with:
- Clear, numbered reproduction steps
- Expected vs actual result
- Roo Code version (required); API provider/model if relevant
- **Security issues**: Report privately via [security advisories](https://github.com/RooCodeInc/Roo-Code/security/advisories/new).
## Development & Submission Process
### Development Setup
1. **Fork & Clone:**
```
git clone https://github.com/YOUR_USERNAME/Roo-Code.git
```
2. **Install Dependencies:**
```
pnpm install
```
3. **Debugging:** Open with VS Code (`F5`).
### Writing Code Guidelines
- One focused PR per feature or fix.
- Follow ESLint and TypeScript best practices.
- Write clear, descriptive commits referencing issues (e.g., `Fixes #123`).
- Provide thorough testing (`npm test`).
- Rebase onto the latest `main` branch before submission.
### Submitting a Pull Request
- Begin as a **Draft PR** if seeking early feedback.
- Clearly describe your changes following the Pull Request Template.
- Link the issue in the PR description/title (e.g., "Fixes #123").
- Provide screenshots/videos for UI changes.
- Indicate if documentation updates are necessary.
### Pull Request Policy
- Must reference an assigned GitHub Issue. To get assigned: comment "Claiming" on the issue and DM **Hannes Rudolph (`hrudolph`)** on [Discord](https://discord.gg/roocode). Assignment will be confirmed in the thread.
- Unlinked PRs may be closed.
- PRs should pass CI tests, align with the roadmap, and have clear documentation.
### Review Process
- **Daily Triage:** Quick checks by maintainers.
- **Weekly In-depth Review:** Comprehensive assessment.
- **Iterate promptly** based on feedback.
## Legal
By contributing, you agree your contributions will be licensed under the Apache 2.0 License, consistent with Roo Code's licensing.

View file

@ -6,11 +6,12 @@ Roo Code respects your privacy and is committed to transparency about how we han
### **Where Your Data Goes (And Where It Doesnt)** ### **Where Your Data Goes (And Where It Doesnt)**
- **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. AI providers may store data per their privacy policies. - **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. If you select Roo Code Cloud as the model provider (proxy mode), your code may transit Roo Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the provider. AI providers may store data per their privacy policies.
- **Commands**: Any commands executed through Roo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have access to or store this data, but AI providers may process it per their privacy policies. - **Commands**: Any commands executed through Roo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have access to or store this data, but AI providers may process it per their privacy policies.
- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. - **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Roo Code Cloud as the provider (proxy mode), prompts may transit Roo Code servers only to forward them to the upstream model and are not stored.
- **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen.
- **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Roo Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings. - **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Roo Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings.
- **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Roo Code makes a secure API call to Roo Code's backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information.
### **How We Use Your Data (If Collected)** ### **How We Use Your Data (If Collected)**

117
README.md
View file

@ -1,5 +1,12 @@
<p align="center"> <p align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a> <a href="https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/roocode"><img src="https://img.shields.io/badge/roocode-000000?style=flat&logo=x&logoColor=white" alt="X"></a>
<a href="https://youtube.com/@roocodeyt?feature=shared"><img src="https://img.shields.io/badge/YouTube-FF0000?style=flat&logo=youtube&logoColor=white" alt="YouTube"></a>
<a href="https://discord.gg/roocode"><img src="https://img.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Join Discord"></a>
<a href="https://www.reddit.com/r/RooCode/"><img src="https://img.shields.io/badge/Join%20r%2FRooCode-FF4500?style=flat&logo=reddit&logoColor=white" alt="Join r/RooCode"></a>
</p>
<p align="center">
<em>Get help fast → <a href="https://discord.gg/roocode">Join Discord</a> • Prefer async? → <a href="https://www.reddit.com/r/RooCode/">Join r/RooCode</a></em>
</p> </p>
# Roo Code # Roo Code
@ -28,7 +35,7 @@
- [简体中文](locales/zh-CN/README.md) - [简体中文](locales/zh-CN/README.md)
- [繁體中文](locales/zh-TW/README.md) - [繁體中文](locales/zh-TW/README.md)
- ... - ...
</details> </details>
--- ---
@ -51,27 +58,119 @@ Roo Code adapts to how you work:
- Ask Mode: fast answers, explanations, and docs - Ask Mode: fast answers, explanations, and docs
- Debug Mode: trace issues, add logs, isolate root causes - Debug Mode: trace issues, add logs, isolate root causes
- Custom Modes: build specialized modes for your team or workflow - Custom Modes: build specialized modes for your team or workflow
- Roomote Control: Roomote Control lets you remotely control tasks running in your local VS Code instance.
Learn more: [Using Modes](https://roocodeinc.github.io/Roo-Code/basic-usage/using-modes) • [Custom Modes](https://roocodeinc.github.io/Roo-Code/advanced-usage/custom-modes) Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control)
## Tutorial & Feature Videos
<div align="center">
| | | |
| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
| <a href="https://www.youtube.com/watch?v=Mcq3r1EPZ-4"><img src="https://img.youtube.com/vi/Mcq3r1EPZ-4/maxresdefault.jpg" width="100%"></a><br><b>Installing Roo Code</b> | <a href="https://www.youtube.com/watch?v=ZBML8h5cCgo"><img src="https://img.youtube.com/vi/ZBML8h5cCgo/maxresdefault.jpg" width="100%"></a><br><b>Configuring Profiles</b> | <a href="https://www.youtube.com/watch?v=r1bpod1VWhg"><img src="https://img.youtube.com/vi/r1bpod1VWhg/maxresdefault.jpg" width="100%"></a><br><b>Codebase Indexing</b> |
| <a href="https://www.youtube.com/watch?v=iiAv1eKOaxk"><img src="https://img.youtube.com/vi/iiAv1eKOaxk/maxresdefault.jpg" width="100%"></a><br><b>Custom Modes</b> | <a href="https://www.youtube.com/watch?v=Ho30nyY332E"><img src="https://img.youtube.com/vi/Ho30nyY332E/maxresdefault.jpg" width="100%"></a><br><b>Checkpoints</b> | <a href="https://www.youtube.com/watch?v=HmnNSasv7T8"><img src="https://img.youtube.com/vi/HmnNSasv7T8/maxresdefault.jpg" width="100%"></a><br><b>Context Management</b> |
</div>
<p align="center">
<a href="https://docs.roocode.com/tutorial-videos">More quick tutorial and feature videos...</a>
</p>
## Resources ## Resources
- **[Documentation](https://roocodeinc.github.io/Roo-Code/):** The official guide to installing, configuring, and mastering Roo Code. - **[Documentation](https://docs.roocode.com):** The official guide to installing, configuring, and mastering Roo Code.
- **[YouTube Channel](https://youtube.com/@roocodeyt?feature=shared):** Watch tutorials and see features in action.
- **[Discord Server](https://discord.gg/roocode):** Join the community for real-time help and discussion.
- **[Reddit Community](https://www.reddit.com/r/RooCode):** Share your experiences and see what others are building.
- **[GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues):** Report bugs and track development. - **[GitHub Issues](https://github.com/RooCodeInc/Roo-Code/issues):** Report bugs and track development.
- **[Feature Requests](https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop):** Have an idea? Share it with the developers.
---
## Local Setup & Development
1. **Clone** the repo:
```sh
git clone https://github.com/RooCodeInc/Roo-Code.git
```
2. **Install dependencies**:
```sh
pnpm install
```
3. **Run the extension**:
There are several ways to run the Roo Code extension:
### Development Mode (F5)
For active development, use VSCode's built-in debugging:
Press `F5` (or go to **Run****Start Debugging**) in VSCode. This will open a new VSCode window with the Roo Code extension running.
- Changes to the webview will appear immediately.
- Changes to the core extension will also hot reload automatically.
### Automated VSIX Installation
To build and install the extension as a VSIX package directly into VSCode:
```sh
pnpm install:vsix [-y] [--editor=<command>]
```
This command will:
- Ask which editor command to use (code/cursor/code-insiders) - defaults to 'code'
- Uninstall any existing version of the extension.
- Build the latest VSIX package.
- Install the newly built VSIX.
- Prompt you to restart VS Code for changes to take effect.
Options:
- `-y`: Skip all confirmation prompts and use defaults
- `--editor=<command>`: Specify the editor command (e.g., `--editor=cursor` or `--editor=code-insiders`)
### Manual VSIX Installation
If you prefer to install the VSIX package manually:
1. First, build the VSIX package:
```sh
pnpm vsix
```
2. A `.vsix` file will be generated in the `bin/` directory (e.g., `bin/roo-cline-<version>.vsix`).
3. Install it manually using the VSCode CLI:
```sh
code --install-extension bin/roo-cline-<version>.vsix
```
---
We use [changesets](https://github.com/changesets/changesets) for versioning and publishing. Check our `CHANGELOG.md` for release notes.
--- ---
## Disclaimer ## Disclaimer
The Roo Code Extension was shut down on May 15th.
- If you're looking for an alternative, check out [ZooCode](https://github.com/Zoo-Code-Org/Zoo-Code/) (a fork started by the Roo Code community) and [Cline](https://cline.bot/) (from where Roo Code originated).
- If you were a paying user and have billing questions, please write [billing@roocode.com](mailto:billing@roocode.com).
**Please note** that Roo Code, Inc does **not** make any representations or warranties regarding any code, models, or other tools provided or made available in connection with Roo Code, any associated third-party tools, or any resulting outputs. You assume **all risks** associated with the use of any such tools or outputs; such tools are provided on an **"AS IS"** and **"AS AVAILABLE"** basis. Such risks may include, without limitation, intellectual property infringement, cyber vulnerabilities or attacks, bias, inaccuracies, errors, defects, viruses, downtime, property loss or damage, and/or personal injury. You are solely responsible for your use of any such tools or outputs (including, without limitation, the legality, appropriateness, and results thereof). **Please note** that Roo Code, Inc does **not** make any representations or warranties regarding any code, models, or other tools provided or made available in connection with Roo Code, any associated third-party tools, or any resulting outputs. You assume **all risks** associated with the use of any such tools or outputs; such tools are provided on an **"AS IS"** and **"AS AVAILABLE"** basis. Such risks may include, without limitation, intellectual property infringement, cyber vulnerabilities or attacks, bias, inaccuracies, errors, defects, viruses, downtime, property loss or damage, and/or personal injury. You are solely responsible for your use of any such tools or outputs (including, without limitation, the legality, appropriateness, and results thereof).
--- ---
## Contributing
We love community contributions! Get started by reading our [CONTRIBUTING.md](CONTRIBUTING.md).
---
## License ## License
[Apache 2.0 © 2026 Roo Code, Inc.](./LICENSE) [Apache 2.0 © 2025 Roo Code, Inc.](./LICENSE)
---
**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we cant wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding!

View file

@ -5,200 +5,6 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.17] - 2026-03-04
### Added
- **Custom Session ID Support**: New `--create-with-session-id` flag allows specifying a custom UUID session ID when creating tasks. Session IDs are now validated as UUIDs for both create and resume operations, as well as for `start.taskId` in stdin-stream mode.
### Tests
- Added integration coverage for create+resume loading the correct session.
## [0.1.16] - 2026-03-04
### Added
- **Custom Shell Selection**: New `--terminal-shell` flag to specify which shell to use for inline command execution. The shell path is validated at the CLI layer and passed through the standard settings mechanism.
### Tests
- Added integration coverage for stdin stream routing and race invariants.
## [0.1.15] - 2026-03-03
### Fixed
- **Follow-up Routing for Completion Asks**: Fixed routing of follow-up messages when the agent asks for clarification (ask_followup_question) in stdin-stream mode. Messages sent after a completion ask are now correctly delivered to the agent instead of being queued.
## [0.1.14] - 2026-03-03
### Fixed
- **Command Output Streaming**: Ensure full command output is streamed before the done event is emitted, preventing truncated output in stdin-stream mode.
## [0.1.13] - 2026-03-02
### Added
- **Skills as Slash Commands**: Skills are now exposed as slash commands, so you can invoke skill workflows directly from command-style input.
- **Skill Fallback Execution**: When a slash command does not match a command file but matches a skill slug, the CLI can resolve and execute that skill path.
### Changed
- **Slash Command Resolution Priority**: Command precedence is preserved, with skill fallback only used when no matching slash command is found.
### Tests
- Added and updated tests for slash command + skill fallback behavior, including command precedence and duplicate skill-slug handling.
## [0.1.12] - 2026-03-02
### Fixed
- **Command Timeout Handling**: CLI runtime now correctly ignores model-provided background timeouts for commands, ensuring command lifetime is governed solely by the `--timeout` setting.
## [0.1.11] - 2026-03-02
### Added
- **Image Support in Stdin Stream**: The `start` and `message` commands in stdin-stream mode now support an optional `images` field (array of base64 data URIs) to attach images to prompts.
### Fixed
- **Upgrade Version Detection**: Fixed version detection in the `upgrade` command to correctly identify when updates are available.
## [0.1.10] - 2026-03-02
### Added
- **Command Exit Code in Events**: The `tool_result` event for command executions now includes an `exitCode` field, allowing CLI consumers to programmatically distinguish between successful and failed command executions without parsing output text.
## [0.1.9] - 2026-03-02
### Fixed
- **Stdin Stream Cancel Race**: Fixed a race condition during startup cancellation in stdin-stream mode that could cause unexpected behavior when canceling tasks immediately after starting them.
### Tests
- **Integration Test Suite**: Added comprehensive integration test suite for stdin-stream protocol covering cancel, followup, multi-message queue, and shutdown scenarios.
## [0.1.8] - 2026-03-02
### Changed
- **Command Execution Timeout**: Increased timeout for command execution to improve reliability for long-running operations.
### Fixed
- **Stdin Stream Queue Handling**: Fixed stdin stream queued messages and command output streaming to ensure messages are properly processed.
## [0.1.7] - 2026-03-01
### Fixed
- **Stdin Stream Control Flow**: Gracefully handle control-flow errors in stdin-stream mode to prevent unexpected crashes during cancellation and shutdown sequences.
### Changed
- **Type Definitions**: Refactored and simplified JSON event type definitions for better type safety.
## [0.1.6] - 2026-02-27
### Added
- **Consecutive Mistake Limit**: New `--mistake-limit` flag to configure the maximum number of consecutive mistakes before the agent pauses for intervention.
### Changed
- **Workspace-Scoped Sessions**: The `list sessions` command and `--resume` flag now only show and resume sessions from the current workspace directory.
### Fixed
- **Task Configuration Forwarding**: Task configuration (custom modes, disabled tools, etc.) passed via the stdin-prompt-stream protocol is now correctly forwarded to the extension host instead of being silently dropped.
- **Stream Error Recovery**: Improved recovery from streaming errors to prevent task interruption.
## [0.1.5] - 2026-02-26
### Added
- **Session History**: New `list sessions` subcommand to view recent CLI sessions with task IDs, timestamps, and initial prompts.
- **Session Resume**: New `--resume <taskId>` flag to continue a previous session from where it left off.
- **Upgrade Command**: New `upgrade` command to check for and install the latest CLI version.
## [0.1.4] - 2026-02-26
### Fixed
- **Exception Handling**: Improved recovery from unhandled exceptions in the CLI to prevent unexpected crashes.
## [0.1.3] - 2026-02-25
### Fixed
- **Task Resumption**: Fixed an issue where resuming a previously suspended task could fail due to state initialization timing in the extension host.
## [0.1.2] - 2026-02-25
### Changed
- **Streaming Deltas**: Tool use ask messages (command, tool, mcp) are now streamed as structured deltas instead of full snapshots in json-event-emitter for improved efficiency.
- **Task ID Propagation**: Task ID is now generated upfront and propagated through runTask/createTask so currentTaskId is available in extension state immediately.
- **Custom Tools**: Enabled customTools experiment in extension host.
### Fixed
- **Cancel Recovery**: Wait for resumable state after cancel before processing follow-up messages to prevent race conditions in stdin-stream.
- **Custom Tool Schema**: Provide valid empty JSON Schema for custom tools without parameters to fix strict-mode API validation.
- **Path Handling**: Skip paths outside cwd in RooProtectedController to avoid RangeError.
- **Retry Handling**: Silently handle abort during exponential backoff retry countdown.
- Fixed spelling/grammar and casing inconsistencies.
### Added
- **Telemetry Control**: Added `ROO_CODE_DISABLE_TELEMETRY=1` environment variable to disable cloud telemetry.
## [0.1.1] - 2026-02-24
### Added
- **Roo Model Warmup**: When configured with the Roo provider, the CLI now proactively fetches and warms the model list during activation so that model information is available before the first prompt is sent. The warmup has a 10s timeout and failures are logged only in debug mode.
- **Unbound Provider**: Added Unbound as an available provider option.
## [0.1.0] - 2026-02-19
### Added
- **NDJSON Stdin Protocol**: Overhauled the stdin prompt stream from raw text lines to a structured NDJSON command protocol (`start`/`message`/`cancel`/`ping`/`shutdown`) with requestId correlation, ack/done/error lifecycle events, and queue telemetry. See [`stdin-stream.ts`](src/ui/stdin-stream.ts) for implementation.
- **List Subcommands**: New `list` subcommands (`commands`, `modes`, `models`) for programmatic discovery of available CLI capabilities.
- **Shared Utilities**: Added `isRecord` guard utility for improved type safety.
### Changed
- **Modularized Architecture**: Extracted stdin stream logic from `run.ts` into dedicated [`stdin-stream.ts`](src/ui/stdin-stream.ts) module for better code organization and maintainability.
### Fixed
- Fixed a bug in `Task.ts` affecting CLI operation.
## [0.0.55] - 2026-02-17
### Fixed
- **Stdin Stream Mode**: Fixed issue where new tasks were incorrectly being created in stdin-prompt-stream mode. The mode now properly reuses the existing task for subsequent prompts instead of creating new tasks.
## [0.0.54] - 2026-02-15
### Added
- **Stdin Stream Mode**: New `stdin-prompt-stream` mode that reads prompts from stdin, allowing batch processing and piping multiple tasks. Each line of stdin is processed as a separate prompt with streaming JSON output. See [`stdin-prompt-stream.ts`](src/ui/stdin-prompt-stream.ts) for implementation.
### Fixed
- Fixed JSON emitter state not being cleared between tasks in stdin-prompt-stream mode
- Fixed inconsistent user role for prompt echo partials in stream-json mode
## [0.0.53] - 2026-02-12 ## [0.0.53] - 2026-02-12
### Changed ### Changed

View file

@ -41,18 +41,27 @@ Re-run the install script to update to the latest version:
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
``` ```
Or run:
```bash
roo upgrade
```
### Uninstalling ### Uninstalling
```bash ```bash
rm -rf ~/.roo/cli ~/.local/bin/roo rm -rf ~/.roo/cli ~/.local/bin/roo
``` ```
### Development Installation
For contributing or development:
```bash
# From the monorepo root.
pnpm install
# Build the main extension first.
pnpm --filter roo-cline bundle
# Build the cli.
pnpm --filter @roo-code/cli build
```
## Usage ## Usage
### Interactive Mode (Default) ### Interactive Mode (Default)
@ -91,53 +100,77 @@ In approval-required mode:
- Tool, command, browser, and MCP actions prompt for yes/no approval - Tool, command, browser, and MCP actions prompt for yes/no approval
- Followup questions wait for manual input (no auto-timeout) - Followup questions wait for manual input (no auto-timeout)
### Print Mode (`--print`) ### Roo Code Cloud Authentication
Use `--print` for non-interactive execution and machine-readable output: To use Roo Code Cloud features (like the provider proxy), you need to authenticate:
```bash ```bash
# Prompt is required # Log in to Roo Code Cloud (opens browser)
roo --print "Summarize this repository" roo auth login
# Create a new task with a specific session ID (UUID) # Check authentication status
roo --print --create-with-session-id 018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87 "Summarize this repository" roo auth status
# Log out
roo auth logout
``` ```
### Stdin Stream Mode (`--stdin-prompt-stream`) The `auth login` command:
For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`. 1. Opens your browser to authenticate with Roo Code Cloud
Send NDJSON commands via stdin: 2. Receives a secure token via localhost callback
3. Stores the token in `~/.config/roo/credentials.json`
```bash Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when your token expires.
printf '{"command":"start","requestId":"1","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json
# Optional: provide taskId per start command **Authentication Flow:**
printf '{"command":"start","requestId":"1","taskId":"018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87","prompt":"1+1=?"}\n' | roo --print --stdin-prompt-stream --output-format stream-json
```
┌──────┐ ┌─────────┐ ┌───────────────┐
│ CLI │ │ Browser │ │ Roo Code Cloud│
└──┬───┘ └────┬────┘ └───────┬───────┘
│ │ │
│ Open auth URL │ │
│─────────────────>│ │
│ │ │
│ │ Authenticate │
│ │─────────────────────>│
│ │ │
│ │<─────────────────────│
│ │ Token via callback │
<─────────────────│ │
│ │ │
│ Store token │ │
│ │ │
``` ```
## Options ## Options
| Option | Description | Default | | Option | Description | Default |
| --------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------- | | --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- |
| `[prompt]` | Your prompt (positional argument, optional) | None | | `[prompt]` | Your prompt (positional argument, optional) | None |
| `--prompt-file <path>` | Read prompt from a file instead of command line argument | None | | `--prompt-file <path>` | Read prompt from a file instead of command line argument | None |
| `--create-with-session-id <session-id>` | Create a new task using the provided session ID (UUID) | None | | `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory | | `-p, --print` | Print response and exit (non-interactive mode) | `false` |
| `-p, --print` | Print response and exit (non-interactive mode) | `false` | | `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `--stdin-prompt-stream` | Read NDJSON control commands from stdin (requires `--print`) | `false` | | `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected | | `-a, --require-approval` | Require manual approval before actions execute | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | | `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-a, --require-approval` | Require manual approval before actions execute | `false` | | `--provider <provider>` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) |
| `-k, --api-key <key>` | API key for the LLM provider | From env var | | `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` |
| `--provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | | `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-m, --model <model>` | Model to use | `anthropic/claude-opus-4.6` | | `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
| `--mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` | | `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
| `--terminal-shell <path>` | Absolute shell path for inline terminal command execution | Auto-detected shell | | `--oneshot` | Exit upon task completion | `false` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | | `--output-format <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` |
| `--consecutive-mistake-limit <n>` | Consecutive error/repetition limit before guidance prompt (`0` disables the limit) | `10` |
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | ## Auth Commands
| `--oneshot` | Exit upon task completion | `false` |
| `--output-format <format>` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | | Command | Description |
| ----------------- | ---------------------------------- |
| `roo auth login` | Authenticate with Roo Code Cloud |
| `roo auth logout` | Clear stored authentication token |
| `roo auth status` | Show current authentication status |
## Environment Variables ## Environment Variables
@ -145,12 +178,19 @@ The CLI will look for API keys in environment variables if not provided via `--a
| Provider | Environment Variable | | Provider | Environment Variable |
| ----------------- | --------------------------- | | ----------------- | --------------------------- |
| roo | `ROO_API_KEY` |
| anthropic | `ANTHROPIC_API_KEY` | | anthropic | `ANTHROPIC_API_KEY` |
| openai-native | `OPENAI_API_KEY` | | openai-native | `OPENAI_API_KEY` |
| openrouter | `OPENROUTER_API_KEY` | | openrouter | `OPENROUTER_API_KEY` |
| gemini | `GOOGLE_API_KEY` | | gemini | `GOOGLE_API_KEY` |
| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` | | vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` |
**Authentication Environment Variables:**
| Variable | Description |
| ----------------- | -------------------------------------------------------------------- |
| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) |
## Architecture ## Architecture
``` ```
@ -194,7 +234,7 @@ The CLI will look for API keys in environment variables if not provided via `--a
```bash ```bash
# Run directly from source (no build required) # Run directly from source (no build required)
pnpm dev --provider openrouter --api-key $OPENROUTER_API_KEY --print "Hello" pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello"
# Run tests # Run tests
pnpm test pnpm test
@ -206,6 +246,12 @@ pnpm check-types
pnpm lint pnpm lint
``` ```
By default the `start` script points `ROO_CODE_PROVIDER_URL` at `http://localhost:8080/proxy` for local development. To point at the production API instead, override the environment variable:
```bash
ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello"
```
## Releasing ## Releasing
Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`. Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`.

View file

@ -104,60 +104,12 @@ get_version() {
error "Failed to fetch releases from GitHub. Check your internet connection." error "Failed to fetch releases from GitHub. Check your internet connection."
} }
# Extract highest cli-v* tag by semantic version (do not rely on API ordering) # Extract the latest cli-v* tag
VERSION=$(printf "%s" "$RELEASES_JSON" | node -e ' VERSION=$(echo "$RELEASES_JSON" |
const fs = require("fs") grep -o '"tag_name": "cli-v[^"]*"' |
const input = fs.readFileSync(0, "utf8") head -1 |
let releases sed 's/"tag_name": "cli-v//' |
try { sed 's/"//')
releases = JSON.parse(input)
} catch {
process.exit(1)
}
function parseVersion(version) {
const core = String(version).trim().split("+", 1)[0].split("-", 1)[0]
if (!core) return null
const parts = core.split(".")
if (parts.length === 0 || parts.some((part) => !/^\d+$/.test(part))) {
return null
}
return parts.map((part) => Number.parseInt(part, 10))
}
function compareVersions(a, b) {
const maxLength = Math.max(a.length, b.length)
for (let i = 0; i < maxLength; i++) {
const aPart = a[i] ?? 0
const bPart = b[i] ?? 0
if (aPart > bPart) return 1
if (aPart < bPart) return -1
}
return 0
}
let latestVersion = ""
let latestParts = null
if (Array.isArray(releases)) {
for (const release of releases) {
if (!release || typeof release.tag_name !== "string" || !release.tag_name.startsWith("cli-v")) {
continue
}
const candidate = release.tag_name.slice("cli-v".length)
const candidateParts = parseVersion(candidate)
if (!candidateParts) continue
if (!latestParts || compareVersions(candidateParts, latestParts) > 0) {
latestVersion = candidate
latestParts = candidateParts
}
}
}
if (latestVersion) {
process.stdout.write(latestVersion)
}
')
if [ -z "$VERSION" ]; then if [ -z "$VERSION" ]; then
error "Could not find any CLI releases. The CLI may not have been released yet." error "Could not find any CLI releases. The CLI may not have been released yet."

View file

@ -1,6 +1,6 @@
{ {
"name": "@roo-code/cli", "name": "@roo-code/cli",
"version": "0.1.17", "version": "0.0.53",
"description": "Roo Code CLI - Run the Roo Code agent from the command line", "description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true, "private": true,
"type": "module", "type": "module",
@ -13,11 +13,10 @@
"lint": "eslint src --ext .ts --max-warnings=0", "lint": "eslint src --ext .ts --max-warnings=0",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:integration": "tsx scripts/integration/run.ts",
"build": "tsup", "build": "tsup",
"build:extension": "pnpm --filter roo-cline bundle", "build:extension": "pnpm --filter roo-cline bundle",
"dev": "tsx src/index.ts", "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts",
"dev:local": "tsx src/index.ts", "dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
"clean": "rimraf dist .turbo" "clean": "rimraf dist .turbo"
}, },
"dependencies": { "dependencies": {

View file

@ -193,7 +193,6 @@ create_tarball() {
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { existsSync } from 'fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -201,10 +200,7 @@ const __dirname = dirname(__filename);
// Set environment variables for the CLI // Set environment variables for the CLI
process.env.ROO_CLI_ROOT = join(__dirname, '..'); process.env.ROO_CLI_ROOT = join(__dirname, '..');
process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
const ripgrepPath = join(__dirname, 'rg'); process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');
if (existsSync(ripgrepPath)) {
process.env.ROO_RIPGREP_PATH = ripgrepPath;
}
// Import and run the actual CLI // Import and run the actual CLI
await import(join(__dirname, '..', 'lib', 'index.js')); await import(join(__dirname, '..', 'lib', 'index.js'));
@ -215,21 +211,10 @@ WRAPPER_EOF
# Create empty .env file # Create empty .env file
touch "$RELEASE_DIR/.env" touch "$RELEASE_DIR/.env"
# Strip macOS metadata artifacts before packaging.
find "$RELEASE_DIR" -type f -name "._*" -delete
find "$RELEASE_DIR" -type f -name ".DS_Store" -delete
find "$RELEASE_DIR" -type d -name "__MACOSX" -prune -exec rm -rf {} +
# Create tarball # Create tarball
info "Creating tarball..." info "Creating tarball..."
cd "$REPO_ROOT" cd "$REPO_ROOT"
COPYFILE_DISABLE=1 tar \ tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
--exclude="._*" \
--exclude=".DS_Store" \
--exclude="__MACOSX" \
--exclude="*/._*" \
--exclude="*/.DS_Store" \
-czvf "$TARBALL" "$(basename "$RELEASE_DIR")"
# Clean up release directory # Clean up release directory
rm -rf "$RELEASE_DIR" rm -rf "$RELEASE_DIR"

View file

@ -1,104 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-a-${Date.now()}`
const cancelRequestId = `cancel-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let startCommandToolUseSeen = false
let sentCancel = false
let cancelDone = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId
) {
startAccepted = true
return
}
if (
event.type === "tool_use" &&
event.subtype === "command" &&
event.done === true &&
event.requestId === startRequestId
) {
startCommandToolUseSeen = true
}
if (startAccepted && startCommandToolUseSeen && !sentCancel) {
context.sendCommand({
command: "cancel",
requestId: cancelRequestId,
})
sentCancel = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "cancel" &&
event.requestId === cancelRequestId
) {
if (event.code === "cancel_requested" || event.code === "no_active_task") {
cancelDone = true
}
return
}
if (cancelDone && !sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
return
}
if (event.type === "control" && event.subtype === "error" && event.requestId === cancelRequestId) {
throw new Error(
`cancel command failed with code=${event.code ?? "unknown"} content="${event.content ?? ""}"`,
)
}
if (event.type === "error") {
throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`)
}
},
onTimeoutMessage() {
return `timed out waiting for cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`
},
})
if (!startAccepted || !startCommandToolUseSeen || !sentCancel || !cancelDone || !sentShutdown) {
throw new Error(
`cancel flow did not complete expected transitions (startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`,
)
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,83 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-${Date.now()}`
const cancelRequestId = `cancel-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let sentCancel = false
let cancelDone = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "cancel",
requestId: cancelRequestId,
})
sentCancel = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "cancel" &&
event.requestId === cancelRequestId
) {
if (event.code === "cancel_requested" || event.code === "no_active_task") {
cancelDone = true
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
}
return
}
if (event.type === "error") {
throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`)
}
},
onTimeoutMessage() {
return `timed out waiting for immediate-cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`
},
})
if (!startAccepted || !sentCancel || !cancelDone || !sentShutdown) {
throw new Error(
`immediate-cancel flow did not complete expected transitions (startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`,
)
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,161 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const START_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
const FOLLOWUP_PROMPT = 'After cancellation, reply with only "RACE-OK".'
async function main() {
const startRequestId = `start-${Date.now()}`
const cancelRequestId = `cancel-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentCancelAndFollowup = false
let sentShutdown = false
let cancelDoneCode: string | undefined
let followupDoneCode: string | undefined
let followupResult = ""
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
let sawMessageControlError = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: START_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
if (event.requestId === followupRequestId) {
sawMessageControlError = true
}
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (
!sentCancelAndFollowup &&
event.type === "tool_use" &&
event.requestId === startRequestId &&
event.subtype === "command"
) {
context.sendCommand({
command: "cancel",
requestId: cancelRequestId,
})
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentCancelAndFollowup = true
return
}
if (
event.type === "control" &&
event.command === "cancel" &&
event.subtype === "done" &&
event.requestId === cancelRequestId
) {
cancelDoneCode = event.code
return
}
if (
event.type === "control" &&
event.command === "message" &&
event.subtype === "done" &&
event.requestId === followupRequestId
) {
followupDoneCode = event.code
return
}
if (
event.type === "tool_result" &&
event.requestId === followupRequestId &&
typeof event.content === "string" &&
event.content.includes("<user_message>")
) {
sawMisroutedToolResult = true
return
}
if (event.type === "user" && event.requestId === followupRequestId) {
sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("RACE-OK")
return
}
if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) {
return
}
followupResult = event.content ?? ""
if (followupResult.trim().length === 0) {
throw new Error("follow-up after cancel produced an empty result")
}
if (cancelDoneCode !== "cancel_requested") {
throw new Error(
`cancel done code mismatch; expected cancel_requested, got "${cancelDoneCode ?? "none"}"`,
)
}
if (followupDoneCode !== "responded" && followupDoneCode !== "queued") {
throw new Error(
`unexpected follow-up done code after cancel race; expected responded|queued, got "${followupDoneCode ?? "none"}"`,
)
}
if (sawMessageControlError) {
throw new Error("follow-up message emitted control error in cancel recovery race")
}
if (sawMisroutedToolResult) {
throw new Error(
"follow-up message was misrouted into tool_result (<user_message>) in cancel recovery race",
)
}
if (!sawFollowupUserTurn) {
throw new Error("follow-up after cancel did not appear as a normal user turn")
}
console.log(`[PASS] cancel done code: "${cancelDoneCode}"`)
console.log(`[PASS] follow-up done code: "${followupDoneCode}"`)
console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`)
console.log(`[PASS] follow-up result: "${followupResult}"`)
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
},
onTimeoutMessage() {
return [
"timed out waiting for cancel-message-recovery-race validation",
`initSeen=${initSeen}`,
`sentCancelAndFollowup=${sentCancelAndFollowup}`,
`cancelDoneCode=${cancelDoneCode ?? "none"}`,
`followupDoneCode=${followupDoneCode ?? "none"}`,
`sawFollowupUserTurn=${sawFollowupUserTurn}`,
`sawMisroutedToolResult=${sawMisroutedToolResult}`,
`sawMessageControlError=${sawMessageControlError}`,
`haveFollowupResult=${Boolean(followupResult)}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,73 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
async function main() {
const cancelRequestId = `cancel-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let cancelAckSeen = false
let cancelDoneSeen = false
let shutdownSent = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "cancel",
requestId: cancelRequestId,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "cancel" &&
event.requestId === cancelRequestId
) {
cancelAckSeen = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "cancel" &&
event.requestId === cancelRequestId
) {
cancelDoneSeen = true
if (event.code !== "no_active_task") {
throw new Error(`cancel without task should return no_active_task, got "${event.code ?? "none"}"`)
}
if (event.success !== true) {
throw new Error("cancel without task should be treated as successful no-op")
}
if (!shutdownSent) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
shutdownSent = true
}
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`unexpected control error command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
},
onTimeoutMessage() {
return `timed out waiting for cancel-without-active-task validation (initSeen=${initSeen}, cancelAckSeen=${cancelAckSeen}, cancelDoneSeen=${cancelDoneSeen}, shutdownSent=${shutdownSent})`
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,364 +0,0 @@
import fs from "fs/promises"
import os from "os"
import path from "path"
import readline from "readline"
import { fileURLToPath } from "url"
import { randomUUID } from "crypto"
import { execa } from "execa"
import type { TaskSessionEntry } from "@roo-code/core/cli"
type StreamEvent = {
type?: string
subtype?: string
requestId?: string
command?: string
taskId?: string
content?: string
code?: string
success?: boolean
done?: boolean
}
const RESUME_TIMEOUT_MS = 180_000
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function parseStreamEvent(line: string): StreamEvent | null {
const trimmed = line.trim()
if (!trimmed.startsWith("{")) {
return null
}
try {
return JSON.parse(trimmed) as StreamEvent
} catch {
return null
}
}
async function listSessions(cliRoot: string, workspacePath: string): Promise<TaskSessionEntry[]> {
const result = await execa("pnpm", ["dev", "list", "sessions", "--workspace", workspacePath, "--format", "json"], {
cwd: cliRoot,
reject: false,
})
if (result.exitCode !== 0) {
throw new Error(`list sessions failed with exit code ${result.exitCode}: ${result.stderr || result.stdout}`)
}
const stdoutLines = result.stdout.split("\n")
const jsonStartIndex = stdoutLines.findIndex((line) => line.trim().startsWith("{"))
if (jsonStartIndex === -1) {
throw new Error(`list sessions output did not contain JSON payload: ${result.stdout}`)
}
const jsonPayload = stdoutLines.slice(jsonStartIndex).join("\n").trim()
let parsed: unknown
try {
parsed = JSON.parse(jsonPayload)
} catch (error) {
throw new Error(
`failed to parse list sessions output as JSON: ${error instanceof Error ? error.message : String(error)}`,
)
}
if (
typeof parsed !== "object" ||
parsed === null ||
!("sessions" in parsed) ||
!Array.isArray((parsed as { sessions?: unknown }).sessions)
) {
throw new Error("list sessions output missing sessions array")
}
return (parsed as { sessions: TaskSessionEntry[] }).sessions
}
async function createSessionWithCustomId(
cliRoot: string,
workspacePath: string,
sessionId: string,
prompt: string,
): Promise<void> {
const result = await execa(
"pnpm",
[
"dev",
"--print",
"--provider",
"openrouter",
"--output-format",
"stream-json",
"--workspace",
workspacePath,
"--create-with-session-id",
sessionId,
prompt,
],
{
cwd: cliRoot,
reject: false,
},
)
if (result.exitCode !== 0) {
throw new Error(
`create-with-session-id failed for ${sessionId} with exit code ${result.exitCode}: ${result.stderr || result.stdout}`,
)
}
const lines = result.stdout.split("\n")
const events = lines.map(parseStreamEvent).filter((event): event is StreamEvent => Boolean(event))
const errorEvent = events.find((event) => event.type === "error")
if (errorEvent) {
throw new Error(
`create-with-session-id emitted error for ${sessionId}: code=${errorEvent.code ?? "none"} content=${errorEvent.content ?? ""}`,
)
}
const completion = events.find((event) => event.type === "result" && event.done === true)
if (!completion) {
throw new Error(`create-with-session-id did not emit final result for ${sessionId}`)
}
if (completion.success !== true) {
throw new Error(`create-with-session-id completed unsuccessfully for ${sessionId}`)
}
}
async function resumeSessionAndSendMarker(
cliRoot: string,
workspacePath: string,
sessionId: string,
messageToken: string,
): Promise<void> {
const pingRequestId = `ping-${Date.now()}`
const messageRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
const messagePrompt = `Resume marker token: ${messageToken}. Reply with exactly "ack-${messageToken}".`
const child = execa(
"pnpm",
[
"dev",
"--print",
"--stdin-prompt-stream",
"--provider",
"openrouter",
"--output-format",
"stream-json",
"--workspace",
workspacePath,
"--session-id",
sessionId,
],
{
cwd: cliRoot,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
reject: false,
forceKillAfterDelay: 2_000,
},
)
child.stderr?.on("data", (chunk) => {
process.stderr.write(chunk)
})
let pingSent = false
let messageSent = false
let shutdownSent = false
let sawMessageControlDone = false
let sawUserTurnWithMarker = false
let shutdownTaskId: string | undefined
let handlerError: Error | null = null
let timedOut = false
const sendCommand = (command: { command: "ping" | "message" | "shutdown"; requestId: string; prompt?: string }) => {
if (!child.stdin || child.stdin.destroyed) {
return
}
child.stdin.write(`${JSON.stringify(command)}\n`)
}
const timeout = setTimeout(() => {
timedOut = true
handlerError = new Error(
`timed out resuming session ${sessionId} (pingSent=${pingSent}, messageSent=${messageSent}, sawMessageControlDone=${sawMessageControlDone}, sawUserTurnWithMarker=${sawUserTurnWithMarker})`,
)
child.kill("SIGTERM")
}, RESUME_TIMEOUT_MS)
const rl = readline.createInterface({
input: child.stdout!,
crlfDelay: Infinity,
})
rl.on("line", (line) => {
process.stdout.write(`${line}\n`)
const event = parseStreamEvent(line)
if (!event) {
return
}
if (event.type === "system" && event.subtype === "init" && !pingSent) {
pingSent = true
sendCommand({ command: "ping", requestId: pingRequestId })
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "ping" &&
event.requestId === pingRequestId &&
!messageSent
) {
messageSent = true
sendCommand({
command: "message",
requestId: messageRequestId,
prompt: messagePrompt,
})
return
}
if (
event.type === "control" &&
event.subtype === "error" &&
event.command === "message" &&
event.requestId === messageRequestId
) {
handlerError = new Error(
`message command failed while resuming ${sessionId}: code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
child.kill("SIGTERM")
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === messageRequestId
) {
sawMessageControlDone = true
return
}
if (event.type === "user" && event.requestId === messageRequestId && event.content?.includes(messageToken)) {
sawUserTurnWithMarker = true
if (!shutdownSent) {
shutdownSent = true
sendCommand({ command: "shutdown", requestId: shutdownRequestId })
}
return
}
if (
event.type === "control" &&
(event.subtype === "ack" || event.subtype === "done") &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId &&
typeof event.taskId === "string"
) {
shutdownTaskId = event.taskId
return
}
if (event.type === "control" && event.subtype === "error" && event.requestId !== shutdownRequestId) {
handlerError = new Error(
`unexpected control error while resuming ${sessionId}: command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
child.kill("SIGTERM")
return
}
})
const result = await child
clearTimeout(timeout)
rl.close()
if (handlerError) {
throw handlerError
}
if (timedOut) {
throw new Error(`stream resume for ${sessionId} timed out`)
}
if (result.exitCode !== 0) {
throw new Error(`stream resume for ${sessionId} exited non-zero: ${result.exitCode}`)
}
if (!sawMessageControlDone) {
throw new Error(`did not observe message control completion while resuming ${sessionId}`)
}
if (!sawUserTurnWithMarker) {
throw new Error(`did not observe resumed user marker turn while resuming ${sessionId}`)
}
if (shutdownTaskId !== sessionId) {
throw new Error(
`shutdown taskId did not match resumed session (expected=${sessionId}, actual=${shutdownTaskId ?? "none"})`,
)
}
}
async function main() {
const cliRoot = process.env.ROO_CLI_ROOT
? path.resolve(process.env.ROO_CLI_ROOT)
: path.resolve(__dirname, "../../..")
const workspacePath = await fs.mkdtemp(path.join(os.tmpdir(), "roo-cli-create-session-id-"))
const firstSessionId = randomUUID()
const secondSessionId = randomUUID()
const firstMarker = `FIRST-MARKER-${Date.now()}`
const secondMarker = `SECOND-MARKER-${Date.now()}`
try {
await createSessionWithCustomId(
cliRoot,
workspacePath,
firstSessionId,
`Create first session marker ${firstMarker}. Reply with exactly "ok-${firstMarker}".`,
)
await createSessionWithCustomId(
cliRoot,
workspacePath,
secondSessionId,
`Create second session marker ${secondMarker}. Reply with exactly "ok-${secondMarker}".`,
)
const initialSessions = await listSessions(cliRoot, workspacePath)
if (!initialSessions.some((session) => session.id === firstSessionId)) {
throw new Error(`session list missing first custom session id ${firstSessionId}`)
}
if (!initialSessions.some((session) => session.id === secondSessionId)) {
throw new Error(`session list missing second custom session id ${secondSessionId}`)
}
const resumeMarkerForFirst = `resume-first-${Date.now()}`
await resumeSessionAndSendMarker(cliRoot, workspacePath, firstSessionId, resumeMarkerForFirst)
const resumeMarkerForSecond = `resume-second-${Date.now()}`
await resumeSessionAndSendMarker(cliRoot, workspacePath, secondSessionId, resumeMarkerForSecond)
console.log(`[PASS] created and resumed custom sessions: ${firstSessionId}, ${secondSessionId}`)
} finally {
await fs.rm(workspacePath, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,135 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const FIRST_PROMPT = `What is 1+1? Reply with only "2".`
const FOLLOWUP_PROMPT = `Different question now: what is 3+3? Reply with only "6".`
function parseEventContent(text: string | undefined): string {
return typeof text === "string" ? text : ""
}
function validateFollowupResult(text: string): void {
if (text.trim().length === 0) {
throw new Error("follow-up produced an empty result")
}
}
async function main() {
const startRequestId = `start-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentFollowup = false
let sentShutdown = false
let firstResult = ""
let followupResult = ""
let followupDoneCode: string | undefined
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: FIRST_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (event.type !== "result" || event.done !== true) {
if (
event.type === "control" &&
event.requestId === followupRequestId &&
event.command === "message" &&
event.subtype === "done"
) {
followupDoneCode = event.code
return
}
if (
event.type === "tool_result" &&
event.requestId === followupRequestId &&
typeof event.content === "string" &&
event.content.includes("<user_message>")
) {
sawMisroutedToolResult = true
return
}
if (event.type === "user" && event.requestId === followupRequestId) {
sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3")
return
}
return
}
if (event.requestId === startRequestId) {
firstResult = parseEventContent(event.content)
if (!/\b2\b/.test(firstResult)) {
throw new Error(`first result did not answer first prompt; result="${firstResult}"`)
}
if (!sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
}
return
}
if (event.requestId !== followupRequestId) {
return
}
followupResult = parseEventContent(event.content)
validateFollowupResult(followupResult)
if (followupDoneCode !== "responded") {
throw new Error(
`follow-up message was not routed as ask response; code="${followupDoneCode ?? "none"}"`,
)
}
if (!sawFollowupUserTurn) {
throw new Error("follow-up did not appear as a normal user turn in stream output")
}
if (sawMisroutedToolResult) {
throw new Error("follow-up message was misrouted into tool_result (<user_message>), old bug reproduced")
}
console.log(`[PASS] first result="${firstResult}"`)
console.log(`[PASS] follow-up result="${followupResult}"`)
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
},
onTimeoutMessage() {
return `timed out waiting for completion (initSeen=${initSeen}, sentFollowup=${sentFollowup}, firstResult=${Boolean(firstResult)}, followupResult=${Boolean(followupResult)})`
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,136 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.'
const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".'
const ONE_PIXEL_IMAGE =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9R4WQAAAAASUVORK5CYII="
async function main() {
const startRequestId = `start-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentFollowup = false
let sentShutdown = false
let followupDoneCode: string | undefined
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
let sawQueueImageMetadata = false
let shutdownDoneSeen = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: START_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (
event.type === "control" &&
event.command === "message" &&
event.subtype === "done" &&
event.requestId === followupRequestId
) {
followupDoneCode = event.code
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
return
}
if (
event.type === "control" &&
event.command === "shutdown" &&
event.subtype === "done" &&
event.requestId === shutdownRequestId
) {
shutdownDoneSeen = true
if (followupDoneCode !== "responded") {
throw new Error(
`follow-up image message was not routed as ask response; code="${followupDoneCode ?? "none"}"`,
)
}
if (sawQueueImageMetadata) {
throw new Error("follow-up image message was unexpectedly queued (observed queue image metadata)")
}
if (sawMisroutedToolResult) {
throw new Error("follow-up image message was misrouted into tool_result (<user_message>)")
}
console.log(`[PASS] follow-up image control code: "${followupDoneCode}"`)
console.log(`[PASS] follow-up image user turn observed before shutdown: ${sawFollowupUserTurn}`)
return
}
if (
event.type === "queue" &&
Array.isArray(event.queue) &&
event.queue.some((item) => item?.imageCount === 1)
) {
sawQueueImageMetadata = true
return
}
if (
event.type === "tool_result" &&
event.requestId === followupRequestId &&
typeof event.content === "string" &&
event.content.includes("<user_message>")
) {
sawMisroutedToolResult = true
return
}
if (event.type === "user" && event.requestId === followupRequestId) {
sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3")
return
}
if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
images: [ONE_PIXEL_IMAGE],
})
sentFollowup = true
return
}
},
onTimeoutMessage() {
return [
"timed out waiting for followup-completion-ask-response-images validation",
`initSeen=${initSeen}`,
`sentFollowup=${sentFollowup}`,
`sentShutdown=${sentShutdown}`,
`shutdownDoneSeen=${shutdownDoneSeen}`,
`followupDoneCode=${followupDoneCode ?? "none"}`,
`sawFollowupUserTurn=${sawFollowupUserTurn}`,
`sawMisroutedToolResult=${sawMisroutedToolResult}`,
`sawQueueImageMetadata=${sawQueueImageMetadata}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,153 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.'
const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".'
async function main() {
const startRequestId = `start-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentFollowup = false
let sentShutdown = false
let startAckCount = 0
let sawStartControlAfterFollowup = false
let followupDoneCode: string | undefined
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
let sawQueueEventForFollowupRequest = false
let followupResult = ""
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: START_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (event.type === "control" && event.command === "start" && event.subtype === "ack") {
startAckCount += 1
if (sentFollowup) {
sawStartControlAfterFollowup = true
}
return
}
if (
event.type === "control" &&
event.command === "message" &&
event.subtype === "done" &&
event.requestId === followupRequestId
) {
followupDoneCode = event.code
return
}
if (event.type === "queue" && event.requestId === followupRequestId) {
sawQueueEventForFollowupRequest = true
return
}
if (
event.type === "tool_result" &&
event.requestId === followupRequestId &&
typeof event.content === "string" &&
event.content.includes("<user_message>")
) {
sawMisroutedToolResult = true
return
}
if (event.type === "user" && event.requestId === followupRequestId) {
sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3")
return
}
if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
return
}
if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) {
return
}
followupResult = event.content ?? ""
if (followupResult.trim().length === 0) {
throw new Error("follow-up produced an empty result")
}
if (followupDoneCode !== "responded") {
throw new Error(
`follow-up message was not routed as ask response; code="${followupDoneCode ?? "none"}"`,
)
}
if (sawMisroutedToolResult) {
throw new Error("follow-up message was misrouted into tool_result (<user_message>), old bug reproduced")
}
if (sawQueueEventForFollowupRequest) {
throw new Error("follow-up message produced queue events despite responded routing")
}
if (!sawFollowupUserTurn) {
throw new Error("follow-up did not appear as a normal user turn in stream output")
}
if (sawStartControlAfterFollowup) {
throw new Error("unexpected start control event after follow-up; message should not trigger a new task")
}
if (startAckCount !== 1) {
throw new Error(`expected exactly one start ack event, saw ${startAckCount}`)
}
console.log(`[PASS] follow-up control code: "${followupDoneCode}"`)
console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`)
console.log(`[PASS] follow-up result: "${followupResult}"`)
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
},
onTimeoutMessage() {
return [
"timed out waiting for completion ask-response follow-up validation",
`initSeen=${initSeen}`,
`sentFollowup=${sentFollowup}`,
`startAckCount=${startAckCount}`,
`followupDoneCode=${followupDoneCode ?? "none"}`,
`sawFollowupUserTurn=${sawFollowupUserTurn}`,
`sawMisroutedToolResult=${sawMisroutedToolResult}`,
`sawQueueEventForFollowupRequest=${sawQueueEventForFollowupRequest}`,
`haveFollowupResult=${Boolean(followupResult)}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,159 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.'
const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".'
function looksLikeAttemptCompletionToolUse(event: StreamEvent): boolean {
if (event.type !== "tool_use") {
return false
}
if (event.tool_use?.name === "attempt_completion") {
return true
}
const content = event.content ?? ""
return content.includes('"tool":"attempt_completion"') || content.includes('"name":"attempt_completion"')
}
function validateFollowupResult(text: string): void {
if (text.trim().length === 0) {
throw new Error("follow-up produced an empty result")
}
}
async function main() {
const startRequestId = `start-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentFollowup = false
let sentShutdown = false
let sawAttemptCompletion = false
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
let followupResult = ""
let sawFirstAssistantChunkForStart = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: START_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (!sawAttemptCompletion && looksLikeAttemptCompletionToolUse(event)) {
sawAttemptCompletion = true
if (!sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
}
return
}
if (
event.type === "assistant" &&
event.requestId === startRequestId &&
event.done !== true &&
!sawFirstAssistantChunkForStart
) {
sawFirstAssistantChunkForStart = true
if (!sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
}
return
}
if (
event.type === "tool_result" &&
event.requestId === followupRequestId &&
typeof event.content === "string" &&
event.content.includes("<user_message>")
) {
sawMisroutedToolResult = true
return
}
if (event.type === "user" && event.requestId === followupRequestId) {
sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3")
return
}
if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
return
}
if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) {
return
}
followupResult = event.content ?? ""
validateFollowupResult(followupResult)
if (sawMisroutedToolResult) {
throw new Error("follow-up message was misrouted into tool_result (<user_message>), old bug reproduced")
}
if (!sawFollowupUserTurn) {
throw new Error("follow-up did not appear as a normal user turn in stream output")
}
console.log(`[PASS] saw attempt_completion tool use: ${sawAttemptCompletion}`)
console.log(`[PASS] saw start assistant chunk before follow-up: ${sawFirstAssistantChunkForStart}`)
console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`)
console.log(`[PASS] follow-up result: "${followupResult}"`)
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
},
onTimeoutMessage() {
return [
"timed out waiting for follow-up validation",
`initSeen=${initSeen}`,
`sentFollowup=${sentFollowup}`,
`sawAttemptCompletion=${sawAttemptCompletion}`,
`sawFirstAssistantChunkForStart=${sawFirstAssistantChunkForStart}`,
`sawFollowupUserTurn=${sawFollowupUserTurn}`,
`sawMisroutedToolResult=${sawMisroutedToolResult}`,
`haveFollowupResult=${Boolean(followupResult)}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,124 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 20 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-${Date.now()}`
const messageRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
const testImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
let initSeen = false
let startAccepted = false
let messageAccepted = false
let messageQueued = false
let queueImageCountObserved = false
let shutdownSent = false
let shutdownAck = false
let shutdownDone = false
await runStreamCase({
timeoutMs: 180_000,
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({ command: "start", requestId: startRequestId, prompt: LONG_PROMPT })
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "message",
requestId: messageRequestId,
prompt: "Respond with exactly IMAGE-QUEUED when this message is processed.",
images: [testImage],
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "message" &&
event.requestId === messageRequestId
) {
messageAccepted = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === messageRequestId &&
event.code === "queued"
) {
messageQueued = true
return
}
if (
event.type === "queue" &&
(event.subtype === "snapshot" || event.subtype === "enqueued" || event.subtype === "updated") &&
Array.isArray(event.queue) &&
event.queue.some((item) => item?.imageCount === 1)
) {
queueImageCountObserved = true
if (!shutdownSent) {
context.sendCommand({ command: "shutdown", requestId: shutdownRequestId })
shutdownSent = true
}
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId
) {
shutdownAck = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId
) {
shutdownDone = true
}
},
onTimeoutMessage() {
return `timed out waiting for queue image metadata (initSeen=${initSeen}, startAccepted=${startAccepted}, messageAccepted=${messageAccepted}, messageQueued=${messageQueued}, queueImageCountObserved=${queueImageCountObserved}, shutdownSent=${shutdownSent}, shutdownAck=${shutdownAck}, shutdownDone=${shutdownDone})`
},
})
if (!messageAccepted || !messageQueued || !queueImageCountObserved) {
throw new Error(
`expected queued message with image metadata (messageAccepted=${messageAccepted}, messageQueued=${messageQueued}, queueImageCountObserved=${queueImageCountObserved})`,
)
}
if (!shutdownAck || !shutdownDone) {
throw new Error("shutdown control events were not fully observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,51 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
async function main() {
const messageRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sawNoActiveTaskError = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "message",
requestId: messageRequestId,
prompt: "Hello",
})
return
}
if (
event.type === "control" &&
event.subtype === "error" &&
event.requestId === messageRequestId &&
event.code === "no_active_task"
) {
sawNoActiveTaskError = true
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
}
},
onTimeoutMessage() {
return `timed out waiting for no_active_task error (initSeen=${initSeen}, sawNoActiveTaskError=${sawNoActiveTaskError})`
},
})
if (!sawNoActiveTaskError) {
throw new Error("expected no_active_task error was not observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,148 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const START_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 8 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-${Date.now()}`
const pingARequestId = `ping-a-${Date.now()}`
const messageRequestId = `message-${Date.now()}`
const pingBRequestId = `ping-b-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentInterleavedCommands = false
let sentShutdown = false
const eventOrderByRequestId = new Map<string, string[]>()
let messageDoneCode: string | undefined
let messageQueueEnqueuedSeen = false
let messageResultSeen = false
function recordControlEvent(event: StreamEvent): void {
if (!event.requestId || event.type !== "control" || !event.subtype) {
return
}
const existing = eventOrderByRequestId.get(event.requestId) ?? []
existing.push(event.subtype)
eventOrderByRequestId.set(event.requestId, existing)
}
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: START_PROMPT,
})
return
}
recordControlEvent(event)
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (
!sentInterleavedCommands &&
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId
) {
context.sendCommand({
command: "ping",
requestId: pingARequestId,
})
context.sendCommand({
command: "message",
requestId: messageRequestId,
prompt: 'When this queued message is processed, reply with only "INTERLEAVED".',
})
context.sendCommand({
command: "ping",
requestId: pingBRequestId,
})
sentInterleavedCommands = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === messageRequestId
) {
messageDoneCode = event.code
return
}
if (
event.type === "queue" &&
event.subtype === "enqueued" &&
event.requestId === startRequestId &&
event.queueDepth === 1
) {
messageQueueEnqueuedSeen = true
return
}
if (event.type === "result" && event.done === true && event.requestId === messageRequestId) {
messageResultSeen = true
const pingAOrder = eventOrderByRequestId.get(pingARequestId) ?? []
const pingBOrder = eventOrderByRequestId.get(pingBRequestId) ?? []
const messageOrder = eventOrderByRequestId.get(messageRequestId) ?? []
if (pingAOrder.join(",") !== "ack,done") {
throw new Error(`ping A control order mismatch: ${pingAOrder.join(",") || "none"}`)
}
if (pingBOrder.join(",") !== "ack,done") {
throw new Error(`ping B control order mismatch: ${pingBOrder.join(",") || "none"}`)
}
if (messageOrder.join(",") !== "ack,done") {
throw new Error(`message control order mismatch: ${messageOrder.join(",") || "none"}`)
}
if (messageDoneCode !== "queued") {
throw new Error(
`expected interleaved message done code \"queued\", got \"${messageDoneCode ?? "none"}\"`,
)
}
if (!messageQueueEnqueuedSeen) {
throw new Error("expected queue enqueued event after interleaved message")
}
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
}
},
onTimeoutMessage() {
return [
"timed out waiting for mixed-command-ordering validation",
`initSeen=${initSeen}`,
`sentInterleavedCommands=${sentInterleavedCommands}`,
`messageDoneCode=${messageDoneCode ?? "none"}`,
`messageQueueEnqueuedSeen=${messageQueueEnqueuedSeen}`,
`messageResultSeen=${messageResultSeen}`,
`pingAOrder=${(eventOrderByRequestId.get(pingARequestId) ?? []).join(",") || "none"}`,
`messageOrder=${(eventOrderByRequestId.get(messageRequestId) ?? []).join(",") || "none"}`,
`pingBOrder=${(eventOrderByRequestId.get(pingBRequestId) ?? []).join(",") || "none"}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,184 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 6 && echo "done". After it finishes, reply with exactly "done".'
const MESSAGE_ONE_PROMPT = 'For this follow-up, reply with only "ALPHA".'
const MESSAGE_TWO_PROMPT = 'For this follow-up, reply with only "BETA".'
async function main() {
const startRequestId = `start-${Date.now()}`
const firstMessageRequestId = `message-a-${Date.now()}`
const secondMessageRequestId = `message-b-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let sentQueuedMessages = false
let sentShutdown = false
let firstMessageAccepted = false
let secondMessageAccepted = false
let firstMessageQueued = false
let secondMessageQueued = false
const resultOrder: string[] = []
let queueDequeuedByFirst = false
let queueDrainedBySecond = false
let firstResultSeen = false
let secondResultSeen = false
await runStreamCase({
timeoutMs: 180_000,
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "message",
requestId: firstMessageRequestId,
prompt: MESSAGE_ONE_PROMPT,
})
context.sendCommand({
command: "message",
requestId: secondMessageRequestId,
prompt: MESSAGE_TWO_PROMPT,
})
sentQueuedMessages = true
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "message" &&
event.requestId === firstMessageRequestId
) {
firstMessageAccepted = true
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "message" &&
event.requestId === secondMessageRequestId
) {
secondMessageAccepted = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === firstMessageRequestId &&
event.code === "queued"
) {
firstMessageQueued = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === secondMessageRequestId &&
event.code === "queued"
) {
secondMessageQueued = true
return
}
if (
event.type === "queue" &&
event.subtype === "dequeued" &&
event.requestId === firstMessageRequestId &&
event.queueDepth === 1
) {
queueDequeuedByFirst = true
return
}
if (
event.type === "queue" &&
event.subtype === "drained" &&
event.requestId === secondMessageRequestId &&
event.queueDepth === 0
) {
queueDrainedBySecond = true
return
}
if (event.type === "result" && event.done === true) {
if (event.requestId === firstMessageRequestId) {
firstResultSeen = true
resultOrder.push(firstMessageRequestId)
}
if (event.requestId === secondMessageRequestId) {
secondResultSeen = true
resultOrder.push(secondMessageRequestId)
}
}
if (!firstResultSeen || !secondResultSeen || sentShutdown) {
return
}
const expectedOrder = [firstMessageRequestId, secondMessageRequestId].join(",")
if (resultOrder.join(",") !== expectedOrder) {
throw new Error(
`queued message result order mismatch; expected=${expectedOrder} actual=${resultOrder.join(",")}`,
)
}
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
},
onTimeoutMessage() {
return `timed out waiting for queued message order validation (initSeen=${initSeen}, startAccepted=${startAccepted}, sentQueuedMessages=${sentQueuedMessages}, firstMessageAccepted=${firstMessageAccepted}, secondMessageAccepted=${secondMessageAccepted}, firstMessageQueued=${firstMessageQueued}, secondMessageQueued=${secondMessageQueued}, queueDequeuedByFirst=${queueDequeuedByFirst}, queueDrainedBySecond=${queueDrainedBySecond}, resultOrder=${resultOrder.join(" -> ")}, firstResultSeen=${firstResultSeen}, secondResultSeen=${secondResultSeen})`
},
})
if (
!firstMessageAccepted ||
!secondMessageAccepted ||
!firstMessageQueued ||
!secondMessageQueued ||
!queueDequeuedByFirst ||
!queueDrainedBySecond
) {
throw new Error(
`expected both queued messages to be accepted/queued and queue transitions observed (firstMessageAccepted=${firstMessageAccepted}, secondMessageAccepted=${secondMessageAccepted}, firstMessageQueued=${firstMessageQueued}, secondMessageQueued=${secondMessageQueued}, queueDequeuedByFirst=${queueDequeuedByFirst}, queueDrainedBySecond=${queueDrainedBySecond})`,
)
}
const expectedOrder = [firstMessageRequestId, secondMessageRequestId].join(",")
if (resultOrder.join(",") !== expectedOrder) {
throw new Error(
`queued message result order mismatch; expected=${expectedOrder} actual=${resultOrder.join(",")}`,
)
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,76 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 20 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let shutdownSent = false
let shutdownAck = false
let shutdownDone = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
shutdownSent = true
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId
) {
shutdownAck = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId
) {
shutdownDone = true
}
},
onTimeoutMessage() {
return `timed out waiting for shutdown flow (initSeen=${initSeen}, startAccepted=${startAccepted}, shutdownSent=${shutdownSent}, shutdownAck=${shutdownAck}, shutdownDone=${shutdownDone})`
},
})
if (!shutdownAck || !shutdownDone) {
throw new Error("shutdown control events were not fully observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,77 +0,0 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 8 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const firstStartRequestId = `start-a-${Date.now()}`
const secondStartRequestId = `start-b-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let firstStartAccepted = false
let secondStartSent = false
let sawTaskBusyError = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: firstStartRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === firstStartRequestId &&
!firstStartAccepted
) {
firstStartAccepted = true
context.sendCommand({
command: "start",
requestId: secondStartRequestId,
prompt: "What is 1+1? Reply with only 2.",
})
secondStartSent = true
return
}
if (
event.type === "control" &&
event.subtype === "error" &&
event.command === "start" &&
event.requestId === secondStartRequestId &&
event.code === "task_busy"
) {
sawTaskBusyError = true
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
return
}
},
onTimeoutMessage() {
return `timed out waiting for task_busy error (initSeen=${initSeen}, firstStartAccepted=${firstStartAccepted}, secondStartSent=${secondStartSent}, sawTaskBusyError=${sawTaskBusyError})`
},
})
if (!sawTaskBusyError) {
throw new Error("expected task_busy error for second start command was not observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,152 +0,0 @@
import path from "path"
import { fileURLToPath } from "url"
import readline from "readline"
import { execa } from "execa"
export type StreamEvent = {
type?: string
subtype?: string
requestId?: string
command?: string
content?: string
code?: string
success?: boolean
done?: boolean
id?: number
queueDepth?: number
queue?: Array<{ id?: string; text?: string; imageCount?: number; timestamp?: number }>
tool_use?: {
name?: string
input?: Record<string, unknown>
}
tool_result?: {
name?: string
output?: string
}
}
export type StreamCommand = {
command: "start" | "message" | "cancel" | "ping" | "shutdown"
requestId: string
prompt?: string
images?: string[]
}
export interface StreamCaseContext {
readonly cliRoot: string
readonly timeoutMs: number
nextRequestId(prefix: string): string
sendCommand(command: StreamCommand): void
}
export interface RunStreamCaseOptions {
timeoutMs?: number
onEvent: (event: StreamEvent, context: StreamCaseContext) => void
onTimeoutMessage?: (context: StreamCaseContext) => string
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const defaultCliRoot = path.resolve(__dirname, "../../..")
function parseEvent(line: string): StreamEvent | null {
const trimmed = line.trim()
if (!trimmed.startsWith("{")) {
return null
}
try {
return JSON.parse(trimmed) as StreamEvent
} catch {
return null
}
}
export async function runStreamCase(options: RunStreamCaseOptions): Promise<void> {
const cliRoot = process.env.ROO_CLI_ROOT ? path.resolve(process.env.ROO_CLI_ROOT) : defaultCliRoot
const timeoutMs = options.timeoutMs ?? 120_000
const child = execa(
"pnpm",
["dev", "--print", "--stdin-prompt-stream", "--provider", "openrouter", "--output-format", "stream-json"],
{
cwd: cliRoot,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
reject: false,
forceKillAfterDelay: 2_000,
},
)
child.stderr?.on("data", (chunk) => {
process.stderr.write(chunk)
})
let requestCounter = 0
const context: StreamCaseContext = {
cliRoot,
timeoutMs,
nextRequestId(prefix: string): string {
requestCounter += 1
return `${prefix}-${Date.now()}-${requestCounter}`
},
sendCommand(command: StreamCommand): void {
if (child.stdin?.destroyed) {
return
}
child.stdin.write(`${JSON.stringify(command)}\n`)
},
}
let handlerError: Error | null = null
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
const message = options.onTimeoutMessage?.(context) ?? "timed out waiting for stream scenario completion"
handlerError = new Error(message)
child.kill("SIGTERM")
}, timeoutMs)
const rl = readline.createInterface({
input: child.stdout!,
crlfDelay: Infinity,
})
rl.on("line", (line) => {
process.stdout.write(`${line}\n`)
const event = parseEvent(line)
if (!event) {
return
}
try {
options.onEvent(event, context)
} catch (error) {
handlerError = error instanceof Error ? error : new Error(String(error))
child.kill("SIGTERM")
}
})
const result = await child
clearTimeout(timeout)
rl.close()
if (handlerError) {
throw handlerError
}
if (timedOut) {
throw new Error("stream scenario timed out")
}
if (result.exitCode !== 0) {
throw new Error(`CLI exited with non-zero code: ${result.exitCode}`)
}
}

View file

@ -1,111 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { execa } from "execa"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const cliRoot = path.resolve(__dirname, "../..")
const casesDir = path.resolve(__dirname, "cases")
interface RunnerOptions {
listOnly: boolean
match?: string
}
function parseArgs(argv: string[]): RunnerOptions {
let listOnly = false
let match: string | undefined
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === "--list") {
listOnly = true
continue
}
if (arg === "--match") {
match = argv[i + 1]
i += 1
continue
}
}
return { listOnly, match }
}
async function discoverCaseFiles(match?: string): Promise<string[]> {
const entries = await fs.readdir(casesDir, { withFileTypes: true })
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
.map((entry) => path.resolve(casesDir, entry.name))
.sort((a, b) => a.localeCompare(b))
if (!match) {
return files
}
const normalized = match.toLowerCase()
return files.filter((file) => path.basename(file).toLowerCase().includes(normalized))
}
async function runCase(caseFile: string): Promise<void> {
const caseName = path.basename(caseFile, ".ts")
console.log(`\n[RUN] ${caseName}`)
await execa("tsx", [caseFile], {
cwd: cliRoot,
stdio: "inherit",
reject: true,
env: {
...process.env,
ROO_CLI_ROOT: cliRoot,
},
})
console.log(`[PASS] ${caseName}`)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const caseFiles = await discoverCaseFiles(options.match)
if (caseFiles.length === 0) {
throw new Error(
options.match ? `no integration cases matched --match "${options.match}"` : "no integration cases found",
)
}
if (options.listOnly) {
console.log("Available integration cases:")
for (const file of caseFiles) {
console.log(`- ${path.basename(file, ".ts")}`)
}
return
}
const failures: Array<{ caseName: string; error: string }> = []
for (const caseFile of caseFiles) {
const caseName = path.basename(caseFile, ".ts")
try {
await runCase(caseFile)
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error)
failures.push({ caseName, error: errorText })
console.error(`[FAIL] ${caseName}: ${errorText}`)
}
}
const total = caseFiles.length
const passed = total - failures.length
console.log(`\nSummary: ${passed}/${total} passed`)
if (failures.length > 0) {
process.exitCode = 1
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,35 +0,0 @@
import type { ClineMessage } from "@roo-code/types"
import { detectAgentState } from "../agent-state.js"
import { taskCompleted } from "../events.js"
function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
return { ts: Date.now() + Math.random() * 1000, type: "say", ...overrides }
}
describe("taskCompleted", () => {
it("returns true for completion_result", () => {
const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })])
const current = detectAgentState([createMessage({ type: "ask", ask: "completion_result", partial: false })])
expect(taskCompleted(previous, current)).toBe(true)
})
it("returns true for resume_completed_task", () => {
const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })])
const current = detectAgentState([createMessage({ type: "ask", ask: "resume_completed_task", partial: false })])
expect(taskCompleted(previous, current)).toBe(true)
})
it("returns false for recoverable idle asks", () => {
const previous = detectAgentState([createMessage({ type: "say", say: "text", text: "working" })])
const mistakeLimit = detectAgentState([
createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false }),
])
const apiFailed = detectAgentState([createMessage({ type: "ask", ask: "api_req_failed", partial: false })])
expect(taskCompleted(previous, mistakeLimit)).toBe(false)
expect(taskCompleted(previous, apiFailed)).toBe(false)
})
})

View file

@ -93,6 +93,13 @@ describe("detectAgentState", () => {
expect(state.requiredAction).toBe("answer") expect(state.requiredAction).toBe("answer")
}) })
it("should detect waiting for browser_action_launch approval", () => {
const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.requiredAction).toBe("approve")
})
it("should detect waiting for use_mcp_server approval", () => { it("should detect waiting for use_mcp_server approval", () => {
const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })] const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })]
const state = detectAgentState(messages) const state = detectAgentState(messages)
@ -195,6 +202,7 @@ describe("Type Guards", () => {
expect(isInteractiveAsk("tool")).toBe(true) expect(isInteractiveAsk("tool")).toBe(true)
expect(isInteractiveAsk("command")).toBe(true) expect(isInteractiveAsk("command")).toBe(true)
expect(isInteractiveAsk("followup")).toBe(true) expect(isInteractiveAsk("followup")).toBe(true)
expect(isInteractiveAsk("browser_action_launch")).toBe(true)
expect(isInteractiveAsk("use_mcp_server")).toBe(true) expect(isInteractiveAsk("use_mcp_server")).toBe(true)
}) })

View file

@ -5,8 +5,6 @@ import fs from "fs"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { DEFAULT_FLAGS } from "@/types/index.js"
import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js" import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
import { ExtensionClient } from "../extension-client.js" import { ExtensionClient } from "../extension-client.js"
import { AgentLoopState } from "../agent-state.js" import { AgentLoopState } from "../agent-state.js"
@ -82,28 +80,13 @@ function spyOnPrivate(host: ExtensionHost, method: string) {
} }
describe("ExtensionHost", () => { describe("ExtensionHost", () => {
const initialRooCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks() vi.resetAllMocks()
if (initialRooCliRuntimeEnv === undefined) {
delete process.env.ROO_CLI_RUNTIME
} else {
process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv
}
// Clean up globals // Clean up globals
delete (global as Record<string, unknown>).vscode delete (global as Record<string, unknown>).vscode
delete (global as Record<string, unknown>).__extensionHost delete (global as Record<string, unknown>).__extensionHost
}) })
afterAll(() => {
if (initialRooCliRuntimeEnv === undefined) {
delete process.env.ROO_CLI_RUNTIME
} else {
process.env.ROO_CLI_RUNTIME = initialRooCliRuntimeEnv
}
})
describe("constructor", () => { describe("constructor", () => {
it("should store options correctly", () => { it("should store options correctly", () => {
const options: ExtensionHostOptions = { const options: ExtensionHostOptions = {
@ -152,28 +135,6 @@ describe("ExtensionHost", () => {
expect(getPrivate(host, "promptManager")).toBeDefined() expect(getPrivate(host, "promptManager")).toBeDefined()
expect(getPrivate(host, "askDispatcher")).toBeDefined() expect(getPrivate(host, "askDispatcher")).toBeDefined()
}) })
it("should mark process as CLI runtime", () => {
delete process.env.ROO_CLI_RUNTIME
createTestHost()
expect(process.env.ROO_CLI_RUNTIME).toBe("1")
})
it("should set execaShellPath in initialSettings when terminalShell is provided", () => {
const host = createTestHost({ terminalShell: "/bin/bash" })
const emitSpy = vi.spyOn(host, "emit")
host.markWebviewReady()
const updateSettingsCall = emitSpy.mock.calls.find(
(call) =>
call[0] === "webviewMessage" &&
typeof call[1] === "object" &&
call[1] !== null &&
(call[1] as WebviewMessage).type === "updateSettings",
)
expect(updateSettingsCall).toBeDefined()
const payload = updateSettingsCall?.[1] as WebviewMessage
expect(payload.updatedSettings?.execaShellPath).toBe("/bin/bash")
})
}) })
describe("webview provider registration", () => { describe("webview provider registration", () => {
@ -254,26 +215,6 @@ describe("ExtensionHost", () => {
) )
expect(updateSettingsCall).toBeDefined() expect(updateSettingsCall).toBeDefined()
}) })
it("should force terminalShellIntegrationDisabled when terminalShell is provided", () => {
const host = createTestHost({ terminalShell: "/bin/bash" })
const emitSpy = vi.spyOn(host, "emit")
host.markWebviewReady()
const updateSettingsCall = emitSpy.mock.calls.find(
(call) =>
call[0] === "webviewMessage" &&
typeof call[1] === "object" &&
call[1] !== null &&
(call[1] as WebviewMessage).type === "updateSettings",
)
expect(updateSettingsCall).toBeDefined()
const payload = updateSettingsCall?.[1] as WebviewMessage
expect(payload.type).toBe("updateSettings")
expect(payload.updatedSettings?.terminalShellIntegrationDisabled).toBe(true)
})
}) })
}) })
@ -488,26 +429,6 @@ describe("ExtensionHost", () => {
expect(restoreConsoleSpy).toHaveBeenCalled() expect(restoreConsoleSpy).toHaveBeenCalled()
}) })
it("should clear ROO_CLI_RUNTIME on dispose when it was previously unset", async () => {
delete process.env.ROO_CLI_RUNTIME
host = createTestHost()
expect(process.env.ROO_CLI_RUNTIME).toBe("1")
await host.dispose()
expect(process.env.ROO_CLI_RUNTIME).toBeUndefined()
})
it("should restore prior ROO_CLI_RUNTIME value on dispose", async () => {
process.env.ROO_CLI_RUNTIME = "preexisting-value"
host = createTestHost()
expect(process.env.ROO_CLI_RUNTIME).toBe("1")
await host.dispose()
expect(process.env.ROO_CLI_RUNTIME).toBe("preexisting-value")
})
}) })
describe("runTask", () => { describe("runTask", () => {
@ -540,37 +461,6 @@ describe("ExtensionHost", () => {
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" }) expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "newTask", text: "test prompt" })
}) })
it("should include taskId when provided", async () => {
const host = createTestHost()
host.markWebviewReady()
const emitSpy = vi.spyOn(host, "emit")
const client = getPrivate(host, "client") as ExtensionClient
const taskPromise = host.runTask("test prompt", "task-123")
const taskCompletedEvent = {
success: true,
stateInfo: {
state: AgentLoopState.IDLE,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_task" as const,
description: "Task completed",
},
}
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
await taskPromise
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", {
type: "newTask",
text: "test prompt",
taskId: "task-123",
})
})
it("should resolve when taskCompleted is emitted on client", async () => { it("should resolve when taskCompleted is emitted on client", async () => {
const host = createTestHost() const host = createTestHost()
host.markWebviewReady() host.markWebviewReady()
@ -594,33 +484,6 @@ describe("ExtensionHost", () => {
await expect(taskPromise).resolves.toBeUndefined() await expect(taskPromise).resolves.toBeUndefined()
}) })
it("should send showTaskWithId for resumeTask and resolve on completion", async () => {
const host = createTestHost()
host.markWebviewReady()
const emitSpy = vi.spyOn(host, "emit")
const client = getPrivate(host, "client") as ExtensionClient
const taskPromise = host.resumeTask("task-abc")
const taskCompletedEvent = {
success: true,
stateInfo: {
state: AgentLoopState.IDLE,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_task" as const,
description: "Task completed",
},
}
setTimeout(() => client.getEmitter().emit("taskCompleted", taskCompletedEvent), 10)
await taskPromise
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "showTaskWithId", text: "task-abc" })
})
}) })
describe("initial settings", () => { describe("initial settings", () => {
@ -631,20 +494,6 @@ describe("ExtensionHost", () => {
expect(initialSettings.mode).toBe("architect") expect(initialSettings.mode).toBe("architect")
}) })
it("should use default consecutiveMistakeLimit when not provided", () => {
const host = createTestHost()
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
expect(initialSettings.consecutiveMistakeLimit).toBe(DEFAULT_FLAGS.consecutiveMistakeLimit)
})
it("should set consecutiveMistakeLimit from options", () => {
const host = createTestHost({ consecutiveMistakeLimit: 8 })
const initialSettings = getPrivate<Record<string, unknown>>(host, "initialSettings")
expect(initialSettings.consecutiveMistakeLimit).toBe(8)
})
it("should enable auto-approval in non-interactive mode", () => { it("should enable auto-approval in non-interactive mode", () => {
const host = createTestHost({ nonInteractive: true }) const host = createTestHost({ nonInteractive: true })

View file

@ -1,170 +0,0 @@
import { Writable } from "stream"
import { JsonEventEmitter } from "../json-event-emitter.js"
function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record<string, unknown>[] } {
const chunks: string[] = []
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString())
callback()
},
}) as unknown as NodeJS.WriteStream
// Each write is a JSON line terminated by \n
const lines = () =>
chunks
.join("")
.split("\n")
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as Record<string, unknown>)
return { stdout: writable, lines }
}
describe("JsonEventEmitter control events", () => {
describe("emitControl", () => {
it("emits an ack event with type control", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
emitter.emitControl({
subtype: "ack",
requestId: "req-1",
command: "start",
content: "starting task",
code: "accepted",
success: true,
})
const output = lines()
expect(output).toHaveLength(1)
expect(output[0]!).toMatchObject({
type: "control",
subtype: "ack",
requestId: "req-1",
command: "start",
content: "starting task",
code: "accepted",
success: true,
})
expect(output[0]!.done).toBeUndefined()
})
it("sets done: true for done events", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
emitter.emitControl({
subtype: "done",
requestId: "req-2",
command: "start",
content: "task completed",
code: "task_completed",
success: true,
})
const output = lines()
expect(output[0]!).toMatchObject({ type: "control", subtype: "done", done: true })
})
it("does not set done for error events", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
emitter.emitControl({
subtype: "error",
requestId: "req-3",
command: "start",
content: "something went wrong",
code: "task_error",
success: false,
})
const output = lines()
expect(output[0]!.done).toBeUndefined()
expect(output[0]!.success).toBe(false)
})
})
describe("requestIdProvider", () => {
it("injects requestId from provider when event has none", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({
mode: "stream-json",
stdout,
requestIdProvider: () => "injected-id",
})
emitter.emitControl({ subtype: "ack", content: "test" })
const output = lines()
expect(output[0]!.requestId).toBe("injected-id")
})
it("keeps explicit requestId when provider also returns one", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({
mode: "stream-json",
stdout,
requestIdProvider: () => "provider-id",
})
emitter.emitControl({ subtype: "ack", requestId: "explicit-id", content: "test" })
const output = lines()
expect(output[0]!.requestId).toBe("explicit-id")
})
it("omits requestId when provider returns undefined and event has none", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({
mode: "stream-json",
stdout,
requestIdProvider: () => undefined,
})
emitter.emitControl({ subtype: "ack", content: "test" })
const output = lines()
expect(output[0]!).not.toHaveProperty("requestId")
})
})
describe("emitInit", () => {
it("emits system init with default schema values", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
// emitInit requires a client — we call emitControl to test init-like fields instead.
// emitInit is called internally by attach(), so we test the init fields via options.
// Instead, directly verify the constructor defaults by emitting a control event
// and checking that the emitter was created with correct defaults.
// We can't call emitInit without a client, but we can verify the options
// were stored correctly by checking what emitControl produces.
emitter.emitControl({ subtype: "ack", content: "test" })
// The control event itself doesn't include schema fields, but at least
// we verify the emitter was constructed successfully with defaults.
const output = lines()
expect(output).toHaveLength(1)
})
it("accepts custom schemaVersion, protocol, and capabilities", () => {
const { stdout } = createMockStdout()
// Should not throw when constructed with custom values
const emitter = new JsonEventEmitter({
mode: "stream-json",
stdout,
schemaVersion: 2,
protocol: "custom-protocol",
capabilities: ["stdin:start", "stdin:message"],
})
expect(emitter).toBeDefined()
})
})
})

View file

@ -1,129 +0,0 @@
import type { ClineMessage } from "@roo-code/types"
import { Writable } from "stream"
import type { TaskCompletedEvent } from "../events.js"
import { JsonEventEmitter } from "../json-event-emitter.js"
import { AgentLoopState, type AgentStateInfo } from "../agent-state.js"
function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record<string, unknown>[] } {
const chunks: string[] = []
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString())
callback()
},
}) as unknown as NodeJS.WriteStream
const lines = () =>
chunks
.join("")
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
return { stdout: writable, lines }
}
function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void {
;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage(
message,
false,
)
}
function emitTaskCompleted(emitter: JsonEventEmitter, event: TaskCompletedEvent): void {
;(emitter as unknown as { handleTaskCompleted: (taskCompleted: TaskCompletedEvent) => void }).handleTaskCompleted(
event,
)
}
function createAskCompletionMessage(ts: number, text = ""): ClineMessage {
return {
ts,
type: "ask",
ask: "completion_result",
partial: false,
text,
} as ClineMessage
}
function createCompletedStateInfo(message: ClineMessage): AgentStateInfo {
return {
state: AgentLoopState.IDLE,
isWaitingForInput: true,
isRunning: false,
isStreaming: false,
currentAsk: "completion_result",
requiredAction: "start_task",
lastMessageTs: message.ts,
lastMessage: message,
description: "Task completed successfully. You can provide feedback or start a new task.",
}
}
describe("JsonEventEmitter result emission", () => {
it("prefers current completion message content over stale cached completion text", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
emitMessage(emitter, {
ts: 100,
type: "say",
say: "completion_result",
partial: false,
text: "FIRST",
} as ClineMessage)
const firstCompletionMessage = createAskCompletionMessage(101, "")
emitTaskCompleted(emitter, {
success: true,
stateInfo: createCompletedStateInfo(firstCompletionMessage),
message: firstCompletionMessage,
})
const secondCompletionMessage = createAskCompletionMessage(102, "SECOND")
emitTaskCompleted(emitter, {
success: true,
stateInfo: createCompletedStateInfo(secondCompletionMessage),
message: secondCompletionMessage,
})
const output = lines().filter((line) => line.type === "result")
expect(output).toHaveLength(2)
expect(output[0]?.content).toBe("FIRST")
expect(output[1]?.content).toBe("SECOND")
})
it("clears cached completion text after each result emission", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
emitMessage(emitter, {
ts: 200,
type: "say",
say: "completion_result",
partial: false,
text: "FIRST",
} as ClineMessage)
const firstCompletionMessage = createAskCompletionMessage(201, "")
emitTaskCompleted(emitter, {
success: true,
stateInfo: createCompletedStateInfo(firstCompletionMessage),
message: firstCompletionMessage,
})
const secondCompletionMessage = createAskCompletionMessage(202, "")
emitTaskCompleted(emitter, {
success: true,
stateInfo: createCompletedStateInfo(secondCompletionMessage),
message: secondCompletionMessage,
})
const output = lines().filter((line) => line.type === "result")
expect(output).toHaveLength(2)
expect(output[0]?.content).toBe("FIRST")
expect(output[1]).not.toHaveProperty("content")
})
})

View file

@ -1,389 +0,0 @@
import type { ClineMessage } from "@roo-code/types"
import { Writable } from "stream"
import { JsonEventEmitter } from "../json-event-emitter.js"
function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record<string, unknown>[] } {
const chunks: string[] = []
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString())
callback()
},
}) as unknown as NodeJS.WriteStream
const lines = () =>
chunks
.join("")
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
return { stdout: writable, lines }
}
function emitMessage(emitter: JsonEventEmitter, message: ClineMessage): void {
;(emitter as unknown as { handleMessage: (msg: ClineMessage, isUpdate: boolean) => void }).handleMessage(
message,
false,
)
}
function createAskMessage(overrides: Partial<ClineMessage>): ClineMessage {
return {
ts: 1,
type: "ask",
ask: "tool",
partial: true,
text: "",
...overrides,
} as ClineMessage
}
describe("JsonEventEmitter streaming deltas", () => {
it("streams ask:command partial updates as deltas and emits full final snapshot", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
const id = 101
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: true,
text: "g",
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: true,
text: "gh",
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: true,
text: "gh pr",
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: false,
text: "gh pr",
}),
)
const output = lines()
expect(output).toHaveLength(4)
expect(output[0]).toMatchObject({
type: "tool_use",
id,
subtype: "command",
content: "g",
tool_use: { name: "execute_command", input: { command: "g" } },
})
expect(output[1]).toMatchObject({
type: "tool_use",
id,
subtype: "command",
content: "h",
tool_use: { name: "execute_command", input: { command: "h" } },
})
expect(output[2]).toMatchObject({
type: "tool_use",
id,
subtype: "command",
content: " pr",
tool_use: { name: "execute_command", input: { command: " pr" } },
})
expect(output[3]).toMatchObject({
type: "tool_use",
id,
subtype: "command",
tool_use: { name: "execute_command", input: { command: "gh pr" } },
done: true,
})
expect(output[3]).not.toHaveProperty("content")
})
it("streams ask:tool snapshots as structured deltas and preserves full final payload", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
const id = 202
const first = JSON.stringify({ tool: "readFile", path: "a" })
const second = JSON.stringify({ tool: "readFile", path: "ab" })
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "tool",
partial: true,
text: first,
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "tool",
partial: true,
text: second,
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "tool",
partial: false,
text: second,
}),
)
const output = lines()
expect(output).toHaveLength(3)
expect(output[0]).toMatchObject({
type: "tool_use",
id,
subtype: "tool",
content: first,
tool_use: { name: "readFile" },
})
expect(output[1]).toMatchObject({
type: "tool_use",
id,
subtype: "tool",
content: "b",
tool_use: { name: "readFile" },
})
expect(output[2]).toMatchObject({
type: "tool_use",
id,
subtype: "tool",
tool_use: { name: "readFile", input: { tool: "readFile", path: "ab" } },
done: true,
})
})
it("suppresses duplicate partial tool snapshots with no delta", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
const id = 303
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: true,
text: "gh",
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: true,
text: "gh",
}),
)
emitMessage(
emitter,
createAskMessage({
ts: id,
ask: "command",
partial: true,
text: "gh pr",
}),
)
const output = lines()
expect(output).toHaveLength(2)
expect(output[0]).toMatchObject({ content: "gh" })
expect(output[1]).toMatchObject({ content: " pr" })
})
it("streams say:command_output as deltas and correlates tool_result id to execute_command", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
const commandId = 404
const outputTs = 405
emitMessage(
emitter,
createAskMessage({
ts: commandId,
ask: "command",
partial: false,
text: "echo hello",
}),
)
emitMessage(emitter, {
ts: outputTs,
type: "say",
say: "command_output",
partial: true,
text: "line1\n",
} as ClineMessage)
emitMessage(emitter, {
ts: outputTs,
type: "say",
say: "command_output",
partial: true,
text: "line1\nline2\n",
} as ClineMessage)
emitMessage(emitter, {
ts: outputTs,
type: "say",
say: "command_output",
partial: false,
text: "line1\nline2\n",
} as ClineMessage)
const output = lines()
expect(output).toHaveLength(4)
expect(output[0]).toMatchObject({
type: "tool_use",
id: commandId,
subtype: "command",
tool_use: { name: "execute_command", input: { command: "echo hello" } },
done: true,
})
expect(output[1]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: "line1\n" },
})
expect(output[2]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: "line2\n" },
})
expect(output[3]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command" },
done: true,
})
expect(output[3]).not.toHaveProperty("tool_result.output")
})
it("prefers status-driven command output streaming and suppresses duplicate say completion", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
const commandId = 505
emitMessage(
emitter,
createAskMessage({
ts: commandId,
ask: "command",
partial: false,
text: "echo streamed",
}),
)
emitter.emitCommandOutputChunk("line1\n")
emitter.emitCommandOutputChunk("line1\nline2\n")
emitter.markCommandOutputExited(17)
// This completion say is expected from the extension and should finalize
// the status-driven command_output stream without duplicating content.
emitMessage(emitter, {
ts: 999,
type: "say",
say: "command_output",
partial: false,
text: "line1\nline2\n",
} as ClineMessage)
const output = lines()
expect(output).toHaveLength(4)
expect(output[0]).toMatchObject({
type: "tool_use",
id: commandId,
subtype: "command",
tool_use: { name: "execute_command", input: { command: "echo streamed" } },
done: true,
})
expect(output[1]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: "line1\n" },
})
expect(output[2]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: "line2\n" },
})
expect(output[3]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", exitCode: 17 },
done: true,
})
})
it("flushes remaining output on final say completion after fast status:exited", () => {
const { stdout, lines } = createMockStdout()
const emitter = new JsonEventEmitter({ mode: "stream-json", stdout })
const commandId = 606
emitMessage(
emitter,
createAskMessage({
ts: commandId,
ask: "command",
partial: false,
text: "aws sts get-caller-identity",
}),
)
emitter.emitCommandOutputChunk("{\n")
emitter.markCommandOutputExited(0)
emitMessage(emitter, {
ts: 607,
type: "say",
say: "command_output",
partial: false,
text: '{\n "Account": "123"\n}\n',
} as ClineMessage)
const output = lines()
expect(output).toHaveLength(3)
expect(output[1]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: "{\n" },
})
expect(output[2]).toMatchObject({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: ' "Account": "123"\n}\n', exitCode: 0 },
done: true,
})
})
})

View file

@ -116,7 +116,7 @@ export enum AgentLoopState {
*/ */
export type RequiredAction = export type RequiredAction =
| "none" // No action needed (running/streaming) | "none" // No action needed (running/streaming)
| "approve" // Can approve/reject (tool, command, mcp) | "approve" // Can approve/reject (tool, command, browser, mcp)
| "answer" // Need to answer a question (followup) | "answer" // Need to answer a question (followup)
| "retry_or_new_task" // Can retry or start new task (api_req_failed) | "retry_or_new_task" // Can retry or start new task (api_req_failed)
| "proceed_or_new_task" // Can proceed or start new task (mistake_limit) | "proceed_or_new_task" // Can proceed or start new task (mistake_limit)
@ -221,6 +221,7 @@ function getRequiredAction(ask: ClineAsk): RequiredAction {
return "answer" return "answer"
case "command": case "command":
case "tool": case "tool":
case "browser_action_launch":
case "use_mcp_server": case "use_mcp_server":
return "approve" return "approve"
case "command_output": case "command_output":
@ -263,6 +264,8 @@ function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string {
return "Agent wants to execute a command. Approve or reject." return "Agent wants to execute a command. Approve or reject."
case "tool": case "tool":
return "Agent wants to perform a file operation. Approve or reject." return "Agent wants to perform a file operation. Approve or reject."
case "browser_action_launch":
return "Agent wants to use the browser. Approve or reject."
case "use_mcp_server": case "use_mcp_server":
return "Agent wants to use an MCP server. Approve or reject." return "Agent wants to use an MCP server. Approve or reject."
default: default:

View file

@ -244,7 +244,7 @@ export class AskDispatcher {
} }
/** /**
* Handle interactive asks (followup, command, tool, use_mcp_server). * Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server).
* These require user approval or input. * These require user approval or input.
*/ */
private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> { private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
@ -258,6 +258,9 @@ export class AskDispatcher {
case "tool": case "tool":
return await this.handleToolApproval(ts, text) return await this.handleToolApproval(ts, text)
case "browser_action_launch":
return await this.handleBrowserApproval(ts, text)
case "use_mcp_server": case "use_mcp_server":
return await this.handleMcpApproval(ts, text) return await this.handleMcpApproval(ts, text)
@ -441,6 +444,32 @@ export class AskDispatcher {
} }
} }
/**
* Handle browser action approval.
*/
private async handleBrowserApproval(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[browser action request]")
if (text) {
this.outputManager.output(` Action: ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-approved by extension settings
return { handled: true }
}
try {
const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/** /**
* Handle MCP server access approval. * Handle MCP server access approval.
*/ */

View file

@ -260,7 +260,7 @@ export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo
* Helper to determine if task completed. * Helper to determine if task completed.
*/ */
export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean { export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
const completionAsks = ["completion_result", "resume_completed_task"] const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"]
const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk) const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk) const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
return wasNotComplete && isNowComplete return wasNotComplete && isNowComplete

View file

@ -26,7 +26,7 @@ import type {
import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim"
import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli" import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli"
import { DEFAULT_FLAGS, type SupportedProvider } from "@/types/index.js" import type { SupportedProvider } from "@/types/index.js"
import type { User } from "@/lib/sdk/index.js" import type { User } from "@/lib/sdk/index.js"
import { getProviderSettings } from "@/lib/utils/provider.js" import { getProviderSettings } from "@/lib/utils/provider.js"
import { createEphemeralStorageDir } from "@/lib/storage/index.js" import { createEphemeralStorageDir } from "@/lib/storage/index.js"
@ -66,7 +66,6 @@ const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot()
export interface ExtensionHostOptions { export interface ExtensionHostOptions {
mode: string mode: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
consecutiveMistakeLimit?: number
user: User | null user: User | null
provider: SupportedProvider provider: SupportedProvider
apiKey?: string apiKey?: string
@ -80,7 +79,6 @@ export interface ExtensionHostOptions {
ephemeral: boolean ephemeral: boolean
debug: boolean debug: boolean
exitOnComplete: boolean exitOnComplete: boolean
terminalShell?: string
/** /**
* When true, exit the process on API request errors instead of retrying. * When true, exit the process on API request errors instead of retrying.
*/ */
@ -109,8 +107,7 @@ interface WebviewViewProvider {
export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> { export interface ExtensionHostInterface extends IExtensionHost<ExtensionHostEventMap> {
client: ExtensionClient client: ExtensionClient
activate(): Promise<void> activate(): Promise<void>
runTask(prompt: string, taskId?: string, configuration?: RooCodeSettings, images?: string[]): Promise<void> runTask(prompt: string): Promise<void>
resumeTask(taskId: string): Promise<void>
sendToExtension(message: WebviewMessage): void sendToExtension(message: WebviewMessage): void
dispose(): Promise<void> dispose(): Promise<void>
} }
@ -138,7 +135,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Ephemeral storage. // Ephemeral storage.
private ephemeralStorageDir: string | null = null private ephemeralStorageDir: string | null = null
private previousCliRuntimeEnv: string | undefined
// ========================================================================== // ==========================================================================
// Managers - These do all the heavy lifting // Managers - These do all the heavy lifting
@ -176,10 +172,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
super() super()
this.options = options this.options = options
// Mark this process as CLI runtime so extension code can apply
// CLI-specific behavior without affecting VS Code desktop usage.
this.previousCliRuntimeEnv = process.env.ROO_CLI_RUNTIME
process.env.ROO_CLI_RUNTIME = "1"
// Enable file-based debug logging only when --debug is passed. // Enable file-based debug logging only when --debug is passed.
if (options.debug) { if (options.debug) {
@ -221,12 +213,9 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Populate initial settings. // Populate initial settings.
const baseSettings: RooCodeSettings = { const baseSettings: RooCodeSettings = {
mode: this.options.mode, mode: this.options.mode,
consecutiveMistakeLimit: this.options.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit, commandExecutionTimeout: 30,
commandExecutionTimeout: 300, browserToolEnabled: false,
enableCheckpoints: false, enableCheckpoints: false,
experiments: {
customTools: true,
},
...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model),
} }
@ -238,6 +227,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
alwaysAllowWrite: true, alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: true, alwaysAllowWriteOutsideWorkspace: true,
alwaysAllowWriteProtected: true, alwaysAllowWriteProtected: true,
alwaysAllowBrowser: true,
alwaysAllowMcp: true, alwaysAllowMcp: true,
alwaysAllowModeSwitch: true, alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true, alwaysAllowSubtasks: true,
@ -258,11 +248,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
this.initialSettings.reasoningEffort = this.options.reasoningEffort this.initialSettings.reasoningEffort = this.options.reasoningEffort
} }
} }
if (this.options.terminalShell) {
this.initialSettings.terminalShellIntegrationDisabled = true
this.initialSettings.execaShellPath = this.options.terminalShell
}
} }
// ========================================================================== // ==========================================================================
@ -446,7 +431,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Apply CLI settings to the runtime config and context proxy BEFORE // Apply CLI settings to the runtime config and context proxy BEFORE
// sending webviewDidLaunch. This prevents a race condition where the // sending webviewDidLaunch. This prevents a race condition where the
// webviewDidLaunch handler's first-time init sync reads default state // webviewDidLaunch handler's first-time init sync reads default state
// instead of the CLI-provided settings. // (apiProvider: "anthropic") instead of the CLI-provided settings.
setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>) setRuntimeConfigValues("roo-cline", this.initialSettings as Record<string, unknown>)
this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings })
@ -475,7 +460,9 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// Task Management // Task Management
// ========================================================================== // ==========================================================================
private waitForTaskCompletion(): Promise<void> { public async runTask(prompt: string): Promise<void> {
this.sendToExtension({ type: "newTask", text: prompt })
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const completeHandler = () => { const completeHandler = () => {
cleanup() cleanup()
@ -516,27 +503,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
}) })
} }
public async runTask(
prompt: string,
taskId?: string,
configuration?: RooCodeSettings,
images?: string[],
): Promise<void> {
this.sendToExtension({
type: "newTask",
text: prompt,
taskId,
taskConfiguration: configuration,
...(images !== undefined ? { images } : {}),
})
return this.waitForTaskCompletion()
}
public async resumeTask(taskId: string): Promise<void> {
this.sendToExtension({ type: "showTaskWithId", text: taskId })
return this.waitForTaskCompletion()
}
// ========================================================================== // ==========================================================================
// Public Agent State API // Public Agent State API
// ========================================================================== // ==========================================================================
@ -603,12 +569,5 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac
// NO-OP // NO-OP
} }
} }
// Restore previous CLI runtime marker for process hygiene in tests.
if (this.previousCliRuntimeEnv === undefined) {
delete process.env.ROO_CLI_RUNTIME
} else {
process.env.ROO_CLI_RUNTIME = this.previousCliRuntimeEnv
}
} }
} }

View file

@ -16,11 +16,10 @@
import type { ClineMessage } from "@roo-code/types" import type { ClineMessage } from "@roo-code/types"
import type { JsonEvent, JsonEventCost, JsonEventQueueItem, JsonFinalOutput } from "@/types/json-events.js" import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js"
import type { ExtensionClient } from "./extension-client.js" import type { ExtensionClient } from "./extension-client.js"
import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js" import type { TaskCompletedEvent } from "./events.js"
import { AgentLoopState } from "./agent-state.js"
/** /**
* Options for JsonEventEmitter. * Options for JsonEventEmitter.
@ -30,14 +29,6 @@ export interface JsonEventEmitterOptions {
mode: "json" | "stream-json" mode: "json" | "stream-json"
/** Output stream (defaults to process.stdout) */ /** Output stream (defaults to process.stdout) */
stdout?: NodeJS.WriteStream stdout?: NodeJS.WriteStream
/** Optional request id provider for correlating stream events */
requestIdProvider?: () => string | undefined
/** Transport schema version emitted in system:init */
schemaVersion?: number
/** Transport protocol identifier emitted in system:init */
protocol?: string
/** Supported stdin protocol capabilities emitted in system:init */
capabilities?: string[]
} }
/** /**
@ -90,55 +81,22 @@ const SKIP_SAY_TYPES = new Set([
/** Key offset for reasoning content to avoid collision with text content delta tracking */ /** Key offset for reasoning content to avoid collision with text content delta tracking */
const REASONING_KEY_OFFSET = 1_000_000_000 const REASONING_KEY_OFFSET = 1_000_000_000
/** Grace period to wait for final say:command_output after status:exited */
const COMMAND_OUTPUT_EXIT_GRACE_MS = 250
export class JsonEventEmitter { export class JsonEventEmitter {
private mode: "json" | "stream-json" private mode: "json" | "stream-json"
private stdout: NodeJS.WriteStream private stdout: NodeJS.WriteStream
private events: JsonEvent[] = [] private events: JsonEvent[] = []
private unsubscribers: (() => void)[] = [] private unsubscribers: (() => void)[] = []
private pendingWrites = new Set<Promise<void>>()
private lastCost: JsonEventCost | undefined private lastCost: JsonEventCost | undefined
private requestIdProvider: () => string | undefined
private schemaVersion: number
private protocol: string
private capabilities: string[]
private seenMessageIds = new Set<number>() private seenMessageIds = new Set<number>()
// Track previous content for delta computation // Track previous content for delta computation
private previousContent = new Map<number, string>() private previousContent = new Map<number, string>()
// Track previous tool-use content for structured (non-append-only) delta computation.
private previousToolUseContent = new Map<number, string>()
// Track the currently active execute_command tool_use id for command_output correlation.
private activeCommandToolUseId: number | undefined
// Track command output snapshots by command tool-use id for delta computation.
private previousCommandOutputByToolUseId = new Map<number, string>()
// Track command ids whose output is being streamed from commandExecutionStatus updates.
private statusDrivenCommandOutputIds = new Set<number>()
// Track command ids that already emitted a terminal command_output done event.
private completedCommandOutputIds = new Set<number>()
// Track exited commands awaiting final say:command_output completion.
private pendingCommandCompletionByToolUseId = new Map<number, { exitCode?: number; timer: NodeJS.Timeout }>()
// Track the completion result content // Track the completion result content
private completionResultContent: string | undefined private completionResultContent: string | undefined
// Track the latest assistant text as a fallback for result.content.
private lastAssistantText: string | undefined
// The first non-partial "say:text" per task is the echoed user prompt.
private expectPromptEchoAsUser = true
constructor(options: JsonEventEmitterOptions) { constructor(options: JsonEventEmitterOptions) {
this.mode = options.mode this.mode = options.mode
this.stdout = options.stdout ?? process.stdout this.stdout = options.stdout ?? process.stdout
this.requestIdProvider = options.requestIdProvider ?? (() => undefined)
this.schemaVersion = options.schemaVersion ?? 1
this.protocol = options.protocol ?? "roo-cli-stream"
this.capabilities = options.capabilities ?? [
"stdin:start",
"stdin:message",
"stdin:cancel",
"stdin:ping",
"stdin:shutdown",
]
} }
/** /**
@ -148,72 +106,19 @@ export class JsonEventEmitter {
// Subscribe to message events // Subscribe to message events
const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false)) const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false))
const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true)) const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true))
const unsubStateChange = client.on("stateChange", (event) => this.handleStateChange(event))
const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event)) const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event))
const unsubError = client.on("error", (error) => this.handleError(error)) const unsubError = client.on("error", (error) => this.handleError(error))
this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubStateChange, unsubTaskCompleted, unsubError) this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubTaskCompleted, unsubError)
// Emit init event // Emit init event
this.emitEvent({ this.emitEvent({
type: "system", type: "system",
subtype: "init", subtype: "init",
content: "Task started", content: "Task started",
schemaVersion: this.schemaVersion,
protocol: this.protocol,
capabilities: this.capabilities,
}) })
} }
emitControl(event: {
subtype: "ack" | "done" | "error"
requestId?: string
command?: JsonEvent["command"]
taskId?: string
content?: string
success?: boolean
code?: string
}): void {
this.emitEvent({
type: "control",
subtype: event.subtype,
requestId: event.requestId,
command: event.command,
taskId: event.taskId,
content: event.content,
success: event.success,
code: event.code,
done: event.subtype === "done" ? true : undefined,
})
}
emitQueue(event: {
subtype: "snapshot" | "enqueued" | "dequeued" | "drained" | "updated"
taskId?: string
content?: string
queueDepth: number
queue: JsonEventQueueItem[]
}): void {
this.emitEvent({
type: "queue",
subtype: event.subtype,
taskId: event.taskId,
content: event.content,
queueDepth: event.queueDepth,
queue: event.queue,
})
}
private handleStateChange(event: AgentStateChangeEvent): void {
// Only treat the next say:text as a prompt echo when a new task starts.
if (
event.previousState.state === AgentLoopState.NO_TASK &&
event.currentState.state !== AgentLoopState.NO_TASK
) {
this.expectPromptEchoAsUser = true
}
}
/** /**
* Detach from the client and clean up subscriptions. * Detach from the client and clean up subscriptions.
*/ */
@ -239,60 +144,6 @@ export class JsonEventEmitter {
return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent return fullContent.startsWith(previous) ? fullContent.slice(previous.length) : fullContent
} }
/**
* Compute a compact delta for structured strings (for tool_use snapshots).
*
* Unlike append-only text streams, tool-use payloads are often full snapshots
* where edits happen before a stable suffix (e.g., inside JSON strings). This
* extracts the inserted segment when possible; otherwise it falls back to the
* full snapshot so consumers can recover.
*/
private computeStructuredDelta(msgId: number, fullContent: string | undefined): string | null {
if (!fullContent) {
return null
}
const previous = this.previousToolUseContent.get(msgId) || ""
if (fullContent === previous) {
return null
}
this.previousToolUseContent.set(msgId, fullContent)
if (previous.length === 0) {
return fullContent
}
if (fullContent.startsWith(previous)) {
return fullContent.slice(previous.length)
}
let prefix = 0
while (prefix < previous.length && prefix < fullContent.length && previous[prefix] === fullContent[prefix]) {
prefix++
}
let suffix = 0
while (
suffix < previous.length - prefix &&
suffix < fullContent.length - prefix &&
previous[previous.length - 1 - suffix] === fullContent[fullContent.length - 1 - suffix]
) {
suffix++
}
const isPureInsertion = fullContent.length >= previous.length && prefix + suffix >= previous.length
if (isPureInsertion) {
return fullContent.slice(prefix, fullContent.length - suffix)
}
return fullContent
}
/** /**
* Check if this is a streaming partial message with no new content. * Check if this is a streaming partial message with no new content.
*/ */
@ -300,138 +151,6 @@ export class JsonEventEmitter {
return this.mode === "stream-json" && content === null return this.mode === "stream-json" && content === null
} }
private computeCommandOutputDelta(commandId: number, fullOutput: string | undefined): string | null {
const normalized = fullOutput ?? ""
const previous = this.previousCommandOutputByToolUseId.get(commandId) || ""
if (normalized === previous) {
return null
}
this.previousCommandOutputByToolUseId.set(commandId, normalized)
return normalized.startsWith(previous) ? normalized.slice(previous.length) : normalized
}
private emitCommandOutputEvent(
commandId: number,
fullOutput: string | undefined,
isDone: boolean,
exitCode?: number,
): void {
if (this.mode === "stream-json") {
const outputDelta = this.computeCommandOutputDelta(commandId, fullOutput)
const event: JsonEvent = {
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command" },
}
if (outputDelta !== null && outputDelta.length > 0) {
event.tool_result = { name: "execute_command", output: outputDelta }
}
if (isDone && exitCode !== undefined) {
event.tool_result = {
...(event.tool_result ?? { name: "execute_command" }),
exitCode,
}
}
if (isDone) {
event.done = true
this.clearPendingCommandCompletion(commandId)
this.previousCommandOutputByToolUseId.delete(commandId)
this.statusDrivenCommandOutputIds.delete(commandId)
this.completedCommandOutputIds.add(commandId)
if (this.activeCommandToolUseId === commandId) {
this.activeCommandToolUseId = undefined
}
}
// Suppress empty partial updates that carry no delta.
if (!isDone && outputDelta === null) {
return
}
this.emitEvent(event)
return
}
this.emitEvent({
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: {
name: "execute_command",
output: fullOutput,
...(isDone && exitCode !== undefined ? { exitCode } : {}),
},
...(isDone ? { done: true } : {}),
})
if (isDone) {
this.clearPendingCommandCompletion(commandId)
this.previousCommandOutputByToolUseId.delete(commandId)
this.statusDrivenCommandOutputIds.delete(commandId)
this.completedCommandOutputIds.add(commandId)
if (this.activeCommandToolUseId === commandId) {
this.activeCommandToolUseId = undefined
}
}
}
public emitCommandOutputChunk(outputSnapshot: string): void {
const commandId = this.activeCommandToolUseId
if (commandId === undefined) {
return
}
this.statusDrivenCommandOutputIds.add(commandId)
this.emitCommandOutputEvent(commandId, outputSnapshot, false)
}
public markCommandOutputExited(exitCode?: number): void {
const commandId = this.activeCommandToolUseId
if (commandId === undefined) {
return
}
this.statusDrivenCommandOutputIds.add(commandId)
this.clearPendingCommandCompletion(commandId)
const timer = setTimeout(() => {
// Fallback close if final say:command_output never arrives.
if (!this.pendingCommandCompletionByToolUseId.has(commandId)) {
return
}
this.pendingCommandCompletionByToolUseId.delete(commandId)
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
}, COMMAND_OUTPUT_EXIT_GRACE_MS)
timer.unref?.()
this.pendingCommandCompletionByToolUseId.set(commandId, { exitCode, timer })
}
public emitCommandOutputDone(exitCode?: number): void {
const commandId = this.activeCommandToolUseId
if (commandId === undefined) {
return
}
this.statusDrivenCommandOutputIds.add(commandId)
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
}
private clearPendingCommandCompletion(commandId: number): void {
const pending = this.pendingCommandCompletionByToolUseId.get(commandId)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pendingCommandCompletionByToolUseId.delete(commandId)
}
/** /**
* Get content to send for a message (delta for streaming, full for json mode). * Get content to send for a message (delta for streaming, full for json mode).
*/ */
@ -439,7 +158,6 @@ export class JsonEventEmitter {
if (this.mode === "stream-json" && isPartial) { if (this.mode === "stream-json" && isPartial) {
return this.computeDelta(msgId, text) return this.computeDelta(msgId, text)
} }
return text ?? null return text ?? null
} }
@ -454,19 +172,15 @@ export class JsonEventEmitter {
subtype?: string, subtype?: string,
): JsonEvent { ): JsonEvent {
const event: JsonEvent = { type, id } const event: JsonEvent = { type, id }
if (content !== null) { if (content !== null) {
event.content = content event.content = content
} }
if (subtype) { if (subtype) {
event.subtype = subtype event.subtype = subtype
} }
if (isDone) { if (isDone) {
event.done = true event.done = true
} }
return event return event
} }
@ -489,22 +203,21 @@ export class JsonEventEmitter {
if (isDone) { if (isDone) {
this.seenMessageIds.add(msg.ts) this.seenMessageIds.add(msg.ts)
this.previousContent.delete(msg.ts) this.previousContent.delete(msg.ts)
this.previousToolUseContent.delete(msg.ts) }
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
// Skip if no new content for streaming partial messages
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
return
} }
if (msg.type === "say" && msg.say) { if (msg.type === "say" && msg.say) {
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
// Skip if no new content for streaming partial messages
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
return
}
this.handleSayMessage(msg, contentToSend, isDone) this.handleSayMessage(msg, contentToSend, isDone)
} }
if (msg.type === "ask" && msg.ask) { if (msg.type === "ask" && msg.ask) {
this.handleAskMessage(msg, isDone) this.handleAskMessage(msg, contentToSend, isDone)
} }
} }
@ -514,17 +227,7 @@ export class JsonEventEmitter {
private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void {
switch (msg.say) { switch (msg.say) {
case "text": case "text":
if (this.expectPromptEchoAsUser) { this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone))
this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone))
if (isDone) {
this.expectPromptEchoAsUser = false
}
} else {
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone))
if (msg.text) {
this.lastAssistantText = msg.text
}
}
break break
case "reasoning": case "reasoning":
@ -536,15 +239,15 @@ export class JsonEventEmitter {
break break
case "command_output": case "command_output":
this.handleCommandOutputMessage(msg, isDone) this.emitEvent({
type: "tool_result",
tool_result: { name: "execute_command", output: msg.text },
})
break break
case "user_feedback": case "user_feedback":
case "user_feedback_diff": case "user_feedback_diff":
this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone))
if (isDone) {
this.expectPromptEchoAsUser = false
}
break break
case "api_req_started": { case "api_req_started": {
@ -555,6 +258,15 @@ export class JsonEventEmitter {
break break
} }
case "browser_action":
case "browser_action_result":
this.emitEvent({
type: "tool_result",
subtype: "browser",
tool_result: { name: "browser_action", output: msg.text },
})
break
case "mcp_server_response": case "mcp_server_response":
this.emitEvent({ this.emitEvent({
type: "tool_result", type: "tool_result",
@ -602,31 +314,49 @@ export class JsonEventEmitter {
/** /**
* Handle "ask" type messages. * Handle "ask" type messages.
*/ */
private handleAskMessage(msg: ClineMessage, isDone: boolean): void { private handleAskMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void {
switch (msg.ask) { switch (msg.ask) {
case "tool": case "tool": {
this.handleToolUseAsk(msg, "tool", isDone) const toolInfo = parseToolInfo(msg.text)
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "tool",
tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } },
})
break break
}
case "command": case "command":
this.handleToolUseAsk(msg, "command", isDone) this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "command",
tool_use: { name: "execute_command", input: { command: msg.text } },
})
break
case "browser_action_launch":
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "browser",
tool_use: { name: "browser_action", input: { raw: msg.text } },
})
break break
case "use_mcp_server": case "use_mcp_server":
this.handleToolUseAsk(msg, "mcp", isDone) this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "mcp",
tool_use: { name: "mcp_server", input: { raw: msg.text } },
})
break break
case "followup": { case "followup":
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
// Skip if no new content for streaming partial messages
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
return
}
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup")) this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, "followup"))
break break
}
case "command_output": case "command_output":
// Handled in say type // Handled in say type
@ -640,147 +370,18 @@ export class JsonEventEmitter {
default: default:
if (msg.text) { if (msg.text) {
const contentToSend = this.getContentToSend(msg.ts, msg.text, msg.partial ?? false)
// Skip if no new content for streaming partial messages
if (msg.partial && this.isEmptyStreamingDelta(contentToSend)) {
return
}
this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask)) this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone, msg.ask))
} }
break break
} }
} }
private handleToolUseAsk(msg: ClineMessage, subtype: "tool" | "command" | "mcp", isDone: boolean): void {
const isStreamingPartial = this.mode === "stream-json" && msg.partial === true
const toolInfo = parseToolInfo(msg.text)
if (subtype === "command") {
if (this.activeCommandToolUseId !== undefined && this.activeCommandToolUseId !== msg.ts) {
const previousCommandId = this.activeCommandToolUseId
const pending = this.pendingCommandCompletionByToolUseId.get(previousCommandId)
if (pending) {
clearTimeout(pending.timer)
this.pendingCommandCompletionByToolUseId.delete(previousCommandId)
this.emitCommandOutputEvent(previousCommandId, undefined, true, pending.exitCode)
}
}
this.activeCommandToolUseId = msg.ts
this.completedCommandOutputIds.delete(msg.ts)
this.clearPendingCommandCompletion(msg.ts)
if (isStreamingPartial) {
const commandDelta = this.computeStructuredDelta(msg.ts, msg.text)
if (commandDelta === null) {
return
}
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "command",
content: commandDelta,
tool_use: { name: "execute_command", input: { command: commandDelta } },
})
return
}
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "command",
tool_use: { name: "execute_command", input: { command: msg.text } },
...(isDone ? { done: true } : {}),
})
return
}
if (subtype === "mcp") {
if (isStreamingPartial) {
const mcpDelta = this.computeStructuredDelta(msg.ts, msg.text)
if (mcpDelta === null) {
return
}
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "mcp",
content: mcpDelta,
tool_use: { name: "mcp_server" },
})
return
}
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "mcp",
tool_use: { name: "mcp_server", input: { raw: msg.text } },
...(isDone ? { done: true } : {}),
})
return
}
if (isStreamingPartial) {
const toolDelta = this.computeStructuredDelta(msg.ts, msg.text)
if (toolDelta === null) {
return
}
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "tool",
content: toolDelta,
tool_use: { name: toolInfo?.name ?? "unknown_tool" },
})
return
}
this.emitEvent({
type: "tool_use",
id: msg.ts,
subtype: "tool",
tool_use: toolInfo ?? { name: "unknown_tool", input: { raw: msg.text } },
...(isDone ? { done: true } : {}),
})
}
private handleCommandOutputMessage(msg: ClineMessage, isDone: boolean): void {
const commandId = this.activeCommandToolUseId ?? msg.ts
if (this.completedCommandOutputIds.has(commandId)) {
return
}
const pending = this.pendingCommandCompletionByToolUseId.get(commandId)
if (pending) {
if (!isDone) {
return
}
clearTimeout(pending.timer)
this.pendingCommandCompletionByToolUseId.delete(commandId)
this.emitCommandOutputEvent(commandId, msg.text, true, pending.exitCode)
return
}
if (this.statusDrivenCommandOutputIds.has(commandId)) {
return
}
this.emitCommandOutputEvent(commandId, msg.text, isDone)
}
/** /**
* Handle task completion and emit result event. * Handle task completion and emit result event.
*/ */
private handleTaskCompleted(event: TaskCompletedEvent): void { private handleTaskCompleted(event: TaskCompletedEvent): void {
// Prefer the completion payload from the current event. If it is empty, // Use tracked completion result content, falling back to event message
// fall back to the most recent tracked completion text, then assistant text. const resultContent = this.completionResultContent || event.message?.text
const resultContent = event.message?.text || this.completionResultContent || this.lastAssistantText
this.emitEvent({ this.emitEvent({
type: "result", type: "result",
@ -791,10 +392,6 @@ export class JsonEventEmitter {
cost: this.lastCost, cost: this.lastCost,
}) })
// Prevent stale completion content from leaking into later turns.
this.completionResultContent = undefined
this.lastAssistantText = undefined
// For "json" mode, output the final accumulated result // For "json" mode, output the final accumulated result
if (this.mode === "json") { if (this.mode === "json") {
this.outputFinalResult(event.success, resultContent) this.outputFinalResult(event.success, resultContent)
@ -818,13 +415,10 @@ export class JsonEventEmitter {
* For json mode: accumulate for final output * For json mode: accumulate for final output
*/ */
private emitEvent(event: JsonEvent): void { private emitEvent(event: JsonEvent): void {
const requestId = event.requestId ?? this.requestIdProvider() this.events.push(event)
const payload = requestId ? { ...event, requestId } : event
this.events.push(payload)
if (this.mode === "stream-json") { if (this.mode === "stream-json") {
this.outputLine(payload) this.outputLine(event)
} }
} }
@ -832,7 +426,7 @@ export class JsonEventEmitter {
* Output a single JSON line (NDJSON format). * Output a single JSON line (NDJSON format).
*/ */
private outputLine(data: unknown): void { private outputLine(data: unknown): void {
this.writeToStdout(JSON.stringify(data) + "\n") this.stdout.write(JSON.stringify(data) + "\n")
} }
/** /**
@ -847,31 +441,7 @@ export class JsonEventEmitter {
events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself events: this.events.filter((e) => e.type !== "result"), // Exclude the result event itself
} }
this.writeToStdout(JSON.stringify(output, null, 2) + "\n") this.stdout.write(JSON.stringify(output, null, 2) + "\n")
}
private writeToStdout(content: string): void {
const writePromise = new Promise<void>((resolve, reject) => {
this.stdout.write(content, (error?: Error | null) => {
if (error) {
reject(error)
return
}
resolve()
})
})
this.pendingWrites.add(writePromise)
void writePromise.finally(() => {
this.pendingWrites.delete(writePromise)
})
}
async flush(): Promise<void> {
while (this.pendingWrites.size > 0) {
await Promise.all([...this.pendingWrites])
}
} }
/** /**
@ -889,17 +459,6 @@ export class JsonEventEmitter {
this.lastCost = undefined this.lastCost = undefined
this.seenMessageIds.clear() this.seenMessageIds.clear()
this.previousContent.clear() this.previousContent.clear()
this.previousToolUseContent.clear()
this.activeCommandToolUseId = undefined
this.previousCommandOutputByToolUseId.clear()
this.statusDrivenCommandOutputIds.clear()
this.completedCommandOutputIds.clear()
for (const pending of this.pendingCommandCompletionByToolUseId.values()) {
clearTimeout(pending.timer)
}
this.pendingCommandCompletionByToolUseId.clear()
this.completionResultContent = undefined this.completionResultContent = undefined
this.lastAssistantText = undefined
this.expectPromptEchoAsUser = true
} }
} }

View file

@ -343,16 +343,13 @@ export class MessageProcessor {
// Task completed // Task completed
if (taskCompleted(previousState, currentState)) { if (taskCompleted(previousState, currentState)) {
const completedSuccessfully =
currentState.currentAsk === "completion_result" || currentState.currentAsk === "resume_completed_task"
if (this.options.debug) { if (this.options.debug) {
debugLog("[MessageProcessor] EMIT taskCompleted", { debugLog("[MessageProcessor] EMIT taskCompleted", {
success: completedSuccessfully, success: currentState.currentAsk === "completion_result",
}) })
} }
const completedEvent: TaskCompletedEvent = { const completedEvent: TaskCompletedEvent = {
success: completedSuccessfully, success: currentState.currentAsk === "completion_result",
stateInfo: currentState, stateInfo: currentState,
message: currentState.lastMessage, message: currentState.lastMessage,
} }

View file

@ -85,12 +85,6 @@ export class OutputManager {
*/ */
private currentlyStreamingTs: number | null = null private currentlyStreamingTs: number | null = null
/**
* Track whether a say:completion_result has been streamed,
* so the subsequent ask:completion_result doesn't duplicate the text.
*/
private completionResultStreamed = false
/** /**
* Track first partial logs (for debugging first/last pattern). * Track first partial logs (for debugging first/last pattern).
*/ */
@ -203,7 +197,6 @@ export class OutputManager {
this.displayedMessages.clear() this.displayedMessages.clear()
this.streamedContent.clear() this.streamedContent.clear()
this.currentlyStreamingTs = null this.currentlyStreamingTs = null
this.completionResultStreamed = false
this.loggedFirstPartial.clear() this.loggedFirstPartial.clear()
this.streamingState.next({ ts: null, isStreaming: false }) this.streamingState.next({ ts: null, isStreaming: false })
} }
@ -255,13 +248,8 @@ export class OutputManager {
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete) this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
break break
case "completion_result": // Note: completion_result is an "ask" type, not a "say" type.
// completion_result can arrive as both a "say" (with streamed text) // It is handled via the TaskCompleted event in extension-host.ts
// and an "ask" (handled via TaskCompleted in extension-host.ts).
// Stream the say variant here; the ask variant is handled by
// outputCompletionResult which will skip if already displayed.
this.outputCompletionSayMessage(ts, text, isPartial, alreadyDisplayedComplete)
break
case "error": case "error":
if (!alreadyDisplayedComplete) { if (!alreadyDisplayedComplete) {
@ -413,50 +401,13 @@ export class OutputManager {
} }
} }
/**
* Output a say:completion_result message (streamed text of the completion).
* The subsequent ask:completion_result is handled by outputCompletionResult.
*/
private outputCompletionSayMessage(
ts: number,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
): void {
if (isPartial && text) {
this.streamContent(ts, text, "[assistant]")
this.displayedMessages.set(ts, { ts, text, partial: true })
this.completionResultStreamed = true
} else if (!isPartial && text && !alreadyDisplayedComplete) {
const streamed = this.streamedContent.get(ts)
if (streamed) {
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
const delta = text.slice(streamed.text.length)
this.writeRaw(delta)
}
this.finishStream(ts)
} else {
this.output("\n[assistant]", text)
}
this.displayedMessages.set(ts, { ts, text, partial: false })
this.completionResultStreamed = true
}
}
/** /**
* Output completion message (called from TaskCompleted handler). * Output completion message (called from TaskCompleted handler).
*/ */
outputCompletionResult(ts: number, text: string): void { outputCompletionResult(ts: number, text: string): void {
const previousDisplay = this.displayedMessages.get(ts) const previousDisplay = this.displayedMessages.get(ts)
if (!previousDisplay || previousDisplay.partial) { if (!previousDisplay || previousDisplay.partial) {
if (this.completionResultStreamed) { this.output("\n[task complete]", text || "")
// Text was already streamed via say:completion_result.
this.output("\n[task complete]")
} else {
this.output("\n[task complete]", text || "")
}
this.displayedMessages.set(ts, { ts, text: text || "", partial: false }) this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -1,104 +0,0 @@
import {
isCancellationLikeError,
isExpectedControlFlowError,
isNoActiveTaskLikeError,
isStreamTeardownLikeError,
} from "../cancellation.js"
describe("isCancellationLikeError", () => {
it("returns true for aborted error messages", () => {
expect(isCancellationLikeError(new Error("[RooCode#say] task 123 aborted"))).toBe(true)
expect(isCancellationLikeError("AbortError: operation aborted")).toBe(true)
})
it("returns true for abort/cancel error names and codes", () => {
expect(isCancellationLikeError({ name: "AbortError", message: "stop now" })).toBe(true)
expect(isCancellationLikeError({ code: "ABORT_ERR", message: "aborted" })).toBe(true)
expect(isCancellationLikeError({ code: "ERR_CANCELED", message: "request failed" })).toBe(true)
})
it("returns true for canceled/cancelled error messages", () => {
expect(isCancellationLikeError(new Error("Request canceled"))).toBe(true)
expect(isCancellationLikeError(new Error("request cancelled by user"))).toBe(true)
})
it("returns false for non-cancellation errors", () => {
expect(isCancellationLikeError(new Error("network timeout"))).toBe(false)
expect(isCancellationLikeError("validation failed")).toBe(false)
})
})
describe("isNoActiveTaskLikeError", () => {
it("matches task-settled cancel race messages", () => {
expect(isNoActiveTaskLikeError(new Error("no active task to cancel"))).toBe(true)
expect(isNoActiveTaskLikeError(new Error("task not found"))).toBe(true)
expect(isNoActiveTaskLikeError("already completed")).toBe(true)
})
it("does not match unrelated messages", () => {
expect(isNoActiveTaskLikeError("network timeout")).toBe(false)
})
})
describe("isStreamTeardownLikeError", () => {
it("matches common stream teardown errors", () => {
expect(isStreamTeardownLikeError({ code: "EPIPE", message: "broken pipe" })).toBe(true)
expect(isStreamTeardownLikeError({ code: "ERR_STREAM_DESTROYED", message: "stream destroyed" })).toBe(true)
expect(isStreamTeardownLikeError(new Error("write after end"))).toBe(true)
})
it("does not match unrelated stream errors", () => {
expect(isStreamTeardownLikeError(new Error("permission denied"))).toBe(false)
})
})
describe("isExpectedControlFlowError", () => {
it("returns false when not in stdin stream mode", () => {
expect(
isExpectedControlFlowError(new Error("AbortError: aborted"), {
stdinStreamMode: false,
operation: "runtime",
}),
).toBe(false)
})
it("accepts cancellation-like runtime errors in stdin stream mode", () => {
expect(
isExpectedControlFlowError(new Error("AbortError: aborted"), {
stdinStreamMode: true,
operation: "runtime",
}),
).toBe(true)
})
it("accepts no-active-task races for cancel operations", () => {
expect(
isExpectedControlFlowError(new Error("task not found"), {
stdinStreamMode: true,
operation: "cancel",
}),
).toBe(true)
})
it("accepts stream teardown errors during shutdown", () => {
expect(
isExpectedControlFlowError(
{ code: "EPIPE", message: "broken pipe" },
{
stdinStreamMode: true,
shuttingDown: true,
operation: "runtime",
},
),
).toBe(true)
})
it("rejects unrelated errors", () => {
expect(
isExpectedControlFlowError(new Error("authentication failed"), {
stdinStreamMode: true,
operation: "runtime",
}),
).toBe(false)
})
})

View file

@ -1,84 +0,0 @@
import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
import { listSessions, parseFormat } from "../list.js"
vi.mock("@/lib/task-history/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/task-history/index.js")>()
return {
...actual,
readWorkspaceTaskSessions: vi.fn(),
}
})
describe("parseFormat", () => {
it("defaults to json when undefined", () => {
expect(parseFormat(undefined)).toBe("json")
})
it("returns json for 'json'", () => {
expect(parseFormat("json")).toBe("json")
})
it("returns text for 'text'", () => {
expect(parseFormat("text")).toBe("text")
})
it("is case-insensitive", () => {
expect(parseFormat("JSON")).toBe("json")
expect(parseFormat("Text")).toBe("text")
expect(parseFormat("TEXT")).toBe("text")
})
it("throws on invalid format", () => {
expect(() => parseFormat("xml")).toThrow('Invalid format: xml. Must be "json" or "text".')
})
it("throws on empty string", () => {
expect(() => parseFormat("")).toThrow("Invalid format")
})
})
describe("listSessions", () => {
const workspacePath = process.cwd()
beforeEach(() => {
vi.clearAllMocks()
})
const captureStdout = async (fn: () => Promise<void>): Promise<string> => {
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
try {
await fn()
return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")
} finally {
stdoutSpy.mockRestore()
}
}
it("uses the CLI runtime storage path and prints JSON output", async () => {
vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([
{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" },
])
const output = await captureStdout(() => listSessions({ format: "json", workspace: workspacePath }))
expect(readWorkspaceTaskSessions).toHaveBeenCalledWith(workspacePath)
expect(JSON.parse(output)).toEqual({
workspace: workspacePath,
sessions: [{ id: "s1", task: "Task 1", ts: 1_700_000_000_000, mode: "code" }],
})
})
it("prints tab-delimited text output with ISO timestamps and formatted titles", async () => {
vi.mocked(readWorkspaceTaskSessions).mockResolvedValue([
{ id: "s1", task: "Task 1", ts: Date.UTC(2024, 0, 1, 0, 0, 0) },
{ id: "s2", task: " ", ts: Date.UTC(2024, 0, 1, 1, 0, 0) },
])
const output = await captureStdout(() => listSessions({ format: "text", workspace: workspacePath }))
const lines = output.trim().split("\n")
expect(lines).toEqual(["s1\t2024-01-01T00:00:00.000Z\tTask 1", "s2\t2024-01-01T01:00:00.000Z\t(untitled)"])
})
})

View file

@ -1,247 +0,0 @@
import { parseStdinStreamCommand, shouldSendMessageAsAskResponse } from "../stdin-stream.js"
describe("parseStdinStreamCommand", () => {
describe("valid commands", () => {
it("parses a start command", () => {
const result = parseStdinStreamCommand(
JSON.stringify({ command: "start", requestId: "req-1", prompt: "hello" }),
1,
)
expect(result).toEqual({ command: "start", requestId: "req-1", prompt: "hello" })
})
it("parses a start command with taskId", () => {
const result = parseStdinStreamCommand(
JSON.stringify({
command: "start",
requestId: "req-task-id",
prompt: "hello",
taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87",
}),
1,
)
expect(result).toEqual({
command: "start",
requestId: "req-task-id",
prompt: "hello",
taskId: "018f7fc8-7c96-7f7c-98aa-2ec4ff7f6d87",
})
})
it("parses a message command", () => {
const result = parseStdinStreamCommand(
JSON.stringify({ command: "message", requestId: "req-2", prompt: "follow up" }),
1,
)
expect(result).toEqual({ command: "message", requestId: "req-2", prompt: "follow up" })
})
it("parses start and message images", () => {
const start = parseStdinStreamCommand(
JSON.stringify({
command: "start",
requestId: "req-img-start",
prompt: "hello",
images: ["data:image/jpeg;base64,abc123"],
}),
1,
)
expect(start).toEqual({
command: "start",
requestId: "req-img-start",
prompt: "hello",
images: ["data:image/jpeg;base64,abc123"],
})
const message = parseStdinStreamCommand(
JSON.stringify({
command: "message",
requestId: "req-img-msg",
prompt: "follow up",
images: ["data:image/png;base64,xyz456"],
}),
1,
)
expect(message).toEqual({
command: "message",
requestId: "req-img-msg",
prompt: "follow up",
images: ["data:image/png;base64,xyz456"],
})
})
it.each(["cancel", "ping", "shutdown"] as const)("parses a %s command (no prompt required)", (command) => {
const result = parseStdinStreamCommand(JSON.stringify({ command, requestId: "req-3" }), 1)
expect(result).toEqual({ command, requestId: "req-3" })
})
it("trims whitespace from requestId", () => {
const result = parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " req-4 " }), 1)
expect(result.requestId).toBe("req-4")
})
it("ignores extra fields", () => {
const result = parseStdinStreamCommand(
JSON.stringify({ command: "ping", requestId: "req-5", extra: "ignored", nested: { a: 1 } }),
1,
)
expect(result).toEqual({ command: "ping", requestId: "req-5" })
})
})
describe("invalid input", () => {
it("throws on invalid JSON", () => {
expect(() => parseStdinStreamCommand("not json", 3)).toThrow("stdin command line 3: invalid JSON")
})
it("throws on non-object JSON (string)", () => {
expect(() => parseStdinStreamCommand('"hello"', 1)).toThrow("expected JSON object")
})
it("throws on non-object JSON (array)", () => {
// Arrays pass isRecord (typeof [] === "object") but lack a command field
expect(() => parseStdinStreamCommand("[]", 1)).toThrow('missing string "command"')
})
it("throws on non-object JSON (number)", () => {
expect(() => parseStdinStreamCommand("42", 1)).toThrow("expected JSON object")
})
it("throws on null", () => {
expect(() => parseStdinStreamCommand("null", 1)).toThrow("expected JSON object")
})
it("throws when command field is missing", () => {
expect(() => parseStdinStreamCommand(JSON.stringify({ requestId: "req" }), 5)).toThrow(
'stdin command line 5: missing string "command"',
)
})
it("throws when command is not a string", () => {
expect(() => parseStdinStreamCommand(JSON.stringify({ command: 123, requestId: "req" }), 1)).toThrow(
'missing string "command"',
)
})
it("throws on unsupported command name", () => {
expect(() => parseStdinStreamCommand(JSON.stringify({ command: "unknown", requestId: "req" }), 2)).toThrow(
'stdin command line 2: unsupported command "unknown"',
)
})
it("throws when requestId is missing", () => {
expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping" }), 1)).toThrow(
'missing non-empty string "requestId"',
)
})
it("throws when requestId is empty", () => {
expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " " }), 1)).toThrow(
'missing non-empty string "requestId"',
)
})
it("throws when start command has no prompt", () => {
expect(() => parseStdinStreamCommand(JSON.stringify({ command: "start", requestId: "req" }), 1)).toThrow(
'"start" requires non-empty string "prompt"',
)
})
it("throws when start taskId is empty, not a string, or not a UUID", () => {
expect(() =>
parseStdinStreamCommand(
JSON.stringify({
command: "start",
requestId: "req-start-task-id-empty",
prompt: "hello",
taskId: " ",
}),
1,
),
).toThrow('"start" taskId must be a non-empty string')
expect(() =>
parseStdinStreamCommand(
JSON.stringify({
command: "start",
requestId: "req-start-task-id-num",
prompt: "hello",
taskId: 123,
}),
1,
),
).toThrow('"start" taskId must be a non-empty string')
expect(() =>
parseStdinStreamCommand(
JSON.stringify({
command: "start",
requestId: "req-start-task-id-invalid-format",
prompt: "hello",
taskId: "task-123",
}),
1,
),
).toThrow('"start" taskId must be a valid UUID')
})
it("throws when message command has empty prompt", () => {
expect(() =>
parseStdinStreamCommand(JSON.stringify({ command: "message", requestId: "req", prompt: " " }), 1),
).toThrow('"message" requires non-empty string "prompt"')
})
it("throws when start or message images are not string arrays", () => {
expect(() =>
parseStdinStreamCommand(
JSON.stringify({
command: "start",
requestId: "req-start-img",
prompt: "hello",
images: "not-an-array",
}),
1,
),
).toThrow('"start" images must be an array of strings')
expect(() =>
parseStdinStreamCommand(
JSON.stringify({
command: "message",
requestId: "req-msg-img",
prompt: "follow up",
images: ["ok", 123],
}),
1,
),
).toThrow('"message" images must be an array of strings')
})
})
})
describe("shouldSendMessageAsAskResponse", () => {
it("routes completion_result asks as ask responses", () => {
expect(shouldSendMessageAsAskResponse(true, "completion_result")).toBe(true)
})
it.each([
"followup",
"tool",
"command",
"use_mcp_server",
"resume_task",
"resume_completed_task",
"mistake_limit_reached",
])("routes %s asks as ask responses", (ask) => {
expect(shouldSendMessageAsAskResponse(true, ask)).toBe(true)
})
it("does not route when not waiting for input", () => {
expect(shouldSendMessageAsAskResponse(false, "completion_result")).toBe(false)
})
it("does not route unknown asks", () => {
expect(shouldSendMessageAsAskResponse(true, "unknown")).toBe(false)
expect(shouldSendMessageAsAskResponse(true, undefined)).toBe(false)
})
})

View file

@ -1,93 +0,0 @@
import { compareVersions, getLatestCliVersion, upgrade } from "../upgrade.js"
function createFetchResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response {
const { ok = true, status = 200 } = init
return {
ok,
status,
json: async () => body,
} as Response
}
describe("compareVersions", () => {
it("returns 1 when first version is newer", () => {
expect(compareVersions("0.2.0", "0.1.9")).toBe(1)
})
it("returns -1 when first version is older", () => {
expect(compareVersions("0.1.4", "0.1.5")).toBe(-1)
})
it("returns 0 when versions are equivalent", () => {
expect(compareVersions("v1.2.0", "1.2")).toBe(0)
})
it("supports cli tag prefixes and prerelease metadata", () => {
expect(compareVersions("cli-v1.2.3", "1.2.2")).toBe(1)
expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBe(0)
})
it("compares multi-digit patch versions numerically", () => {
expect(compareVersions("0.1.10", "0.1.9")).toBe(1)
})
})
describe("getLatestCliVersion", () => {
it("returns the highest cli-v release tag from GitHub releases", async () => {
const fetchImpl = (async () =>
createFetchResponse([
{ tag_name: "cli-v0.1.9" },
{ tag_name: "v9.9.9" },
{ tag_name: "cli-v0.1.10" },
{ tag_name: "cli-v0.1.8" },
])) as typeof fetch
await expect(getLatestCliVersion(fetchImpl)).resolves.toBe("0.1.10")
})
it("throws when release check fails", async () => {
const fetchImpl = (async () => createFetchResponse({}, { ok: false, status: 503 })) as typeof fetch
await expect(getLatestCliVersion(fetchImpl)).rejects.toThrow("Failed to check latest version")
})
})
describe("upgrade", () => {
let logSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined)
})
afterEach(() => {
logSpy.mockRestore()
})
it("does not run installer when already up to date", async () => {
const runInstaller = vi.fn(async () => undefined)
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.1.4" }])) as typeof fetch
await upgrade({
currentVersion: "0.1.4",
fetchImpl,
runInstaller,
})
expect(runInstaller).not.toHaveBeenCalled()
expect(logSpy).toHaveBeenCalledWith("Roo CLI is already up to date.")
})
it("runs installer when a newer version is available", async () => {
const runInstaller = vi.fn(async () => undefined)
const fetchImpl = (async () => createFetchResponse([{ tag_name: "cli-v0.2.0" }])) as typeof fetch
await upgrade({
currentVersion: "0.1.4",
fetchImpl,
runInstaller,
})
expect(runInstaller).toHaveBeenCalledTimes(1)
expect(logSpy).toHaveBeenCalledWith("✓ Upgrade completed.")
})
})

View file

@ -1,131 +0,0 @@
const CANCELLATION_ERROR_PATTERNS = ["aborted", "aborterror", "cancelled", "canceled"]
const CANCELLATION_ERROR_NAMES = new Set(["aborterror"])
const CANCELLATION_ERROR_CODES = new Set(["ABORT_ERR", "ERR_CANCELED", "ERR_CANCELLED"])
const NO_ACTIVE_TASK_PATTERNS = [
"no active task",
"no task to cancel",
"task not found",
"unable to find task",
"already completed",
"already cancelled",
"already canceled",
]
const STREAM_TEARDOWN_CODES = new Set(["EPIPE", "ECONNRESET", "ERR_STREAM_DESTROYED", "ERR_STREAM_PREMATURE_CLOSE"])
const STREAM_TEARDOWN_PATTERNS = [
"write after end",
"stream destroyed",
"premature close",
"socket hang up",
"broken pipe",
]
export interface ExpectedControlFlowErrorContext {
stdinStreamMode: boolean
cancelRequested?: boolean
shuttingDown?: boolean
operation?: "runtime" | "client" | "cancel" | "shutdown"
}
interface ErrorMetadata {
message: string
normalizedMessage: string
name?: string
normalizedName?: string
code?: string
}
function getErrorMetadata(error: unknown): ErrorMetadata {
if (error instanceof Error) {
const maybeCode = (error as Error & { code?: unknown }).code
const code = typeof maybeCode === "string" ? maybeCode : undefined
return {
message: error.message,
normalizedMessage: error.message.toLowerCase(),
name: error.name,
normalizedName: error.name.toLowerCase(),
code,
}
}
if (typeof error === "object" && error !== null) {
const nameRaw = (error as { name?: unknown }).name
const messageRaw = (error as { message?: unknown }).message
const codeRaw = (error as { code?: unknown }).code
const message = typeof messageRaw === "string" ? messageRaw : String(error)
return {
message,
normalizedMessage: message.toLowerCase(),
name: typeof nameRaw === "string" ? nameRaw : undefined,
normalizedName: typeof nameRaw === "string" ? nameRaw.toLowerCase() : undefined,
code: typeof codeRaw === "string" ? codeRaw : undefined,
}
}
const message = String(error)
return {
message,
normalizedMessage: message.toLowerCase(),
}
}
/**
* Best-effort classifier for cancellation/abort failures.
*/
export function isCancellationLikeError(error: unknown): boolean {
const details = getErrorMetadata(error)
if (details.code && CANCELLATION_ERROR_CODES.has(details.code)) {
return true
}
if (details.normalizedName && CANCELLATION_ERROR_NAMES.has(details.normalizedName)) {
return true
}
return CANCELLATION_ERROR_PATTERNS.some((pattern) => details.normalizedMessage.includes(pattern))
}
export function isNoActiveTaskLikeError(error: unknown): boolean {
const details = getErrorMetadata(error)
return NO_ACTIVE_TASK_PATTERNS.some((pattern) => details.normalizedMessage.includes(pattern))
}
export function isStreamTeardownLikeError(error: unknown): boolean {
const details = getErrorMetadata(error)
if (details.code && STREAM_TEARDOWN_CODES.has(details.code)) {
return true
}
return STREAM_TEARDOWN_PATTERNS.some((pattern) => details.normalizedMessage.includes(pattern))
}
/**
* Classify errors that should be treated as expected control flow rather than
* fatal failures while handling stdin stream tasks.
*/
export function isExpectedControlFlowError(error: unknown, context: ExpectedControlFlowErrorContext): boolean {
if (!context.stdinStreamMode) {
return false
}
if (context.shuttingDown && isStreamTeardownLikeError(error)) {
return true
}
const isCancelLike = isCancellationLikeError(error)
if (isCancelLike && (context.cancelRequested || context.shuttingDown || context.operation === "runtime")) {
return true
}
if (
isNoActiveTaskLikeError(error) &&
(context.cancelRequested ||
context.shuttingDown ||
context.operation === "cancel" ||
context.operation === "shutdown")
) {
return true
}
return false
}

View file

@ -1,3 +1 @@
export * from "./run.js" export * from "./run.js"
export * from "./list.js"
export * from "./upgrade.js"

View file

@ -1,312 +0,0 @@
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import pWaitFor from "p-wait-for"
import type { TaskSessionEntry } from "@roo-code/core/cli"
import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types"
import { openRouterDefaultModelId } from "@roo-code/types"
import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js"
import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
import { getApiKeyFromEnv } from "@/lib/utils/provider.js"
import { isRecord } from "@/lib/utils/guards.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const REQUEST_TIMEOUT_MS = 10_000
type ListFormat = "json" | "text"
type BaseListOptions = {
workspace?: string
extension?: string
apiKey?: string
format?: string
debug?: boolean
}
type CommandLike = Pick<Command, "name" | "source" | "filePath" | "description" | "argumentHint">
type ModeLike = { slug: string; name: string }
type SessionLike = TaskSessionEntry
type ListHostOptions = { ephemeral: boolean }
export function parseFormat(rawFormat: string | undefined): ListFormat {
const format = (rawFormat ?? "json").toLowerCase()
if (format === "json" || format === "text") {
return format
}
throw new Error(`Invalid format: ${rawFormat}. Must be "json" or "text".`)
}
function resolveWorkspacePath(workspace: string | undefined): string {
const resolved = workspace ? path.resolve(workspace) : process.cwd()
if (!fs.existsSync(resolved)) {
throw new Error(`Workspace path does not exist: ${resolved}`)
}
return resolved
}
function resolveExtensionPath(extension: string | undefined): string {
const resolved = path.resolve(extension || getDefaultExtensionPath(__dirname))
if (!fs.existsSync(path.join(resolved, "extension.js"))) {
throw new Error(`Extension bundle not found at: ${resolved}`)
}
return resolved
}
function outputJson(data: unknown): void {
process.stdout.write(JSON.stringify(data, null, 2) + "\n")
}
function outputCommandsText(commands: CommandLike[]): void {
for (const command of commands) {
const description = command.description ? ` - ${command.description}` : ""
process.stdout.write(`/${command.name} (${command.source})${description}\n`)
}
}
function outputModesText(modes: ModeLike[]): void {
for (const mode of modes) {
process.stdout.write(`${mode.slug}\t${mode.name}\n`)
}
}
function outputModelsText(models: ModelRecord): void {
for (const modelId of Object.keys(models).sort()) {
process.stdout.write(`${modelId}\n`)
}
}
function formatSessionTitle(task: string): string {
const compact = task.replace(/\s+/g, " ").trim()
if (!compact) {
return "(untitled)"
}
return compact.length <= 120 ? compact : `${compact.slice(0, 117)}...`
}
function outputSessionsText(sessions: SessionLike[]): void {
for (const session of sessions) {
const startedAt = Number.isFinite(session.ts) ? new Date(session.ts).toISOString() : "unknown-time"
process.stdout.write(`${session.id}\t${startedAt}\t${formatSessionTitle(session.task)}\n`)
}
}
async function createListHost(options: BaseListOptions, hostOptions: ListHostOptions): Promise<ExtensionHost> {
const workspacePath = resolveWorkspacePath(options.workspace)
const extensionPath = resolveExtensionPath(options.extension)
const apiKey = options.apiKey || getApiKeyFromEnv("openrouter")
const extensionHostOptions: ExtensionHostOptions = {
mode: "code",
reasoningEffort: undefined,
user: null,
provider: "openrouter",
model: openRouterDefaultModelId,
apiKey,
workspacePath,
extensionPath,
nonInteractive: true,
ephemeral: hostOptions.ephemeral,
debug: options.debug ?? false,
exitOnComplete: true,
exitOnError: false,
disableOutput: true,
}
const host = new ExtensionHost(extensionHostOptions)
await host.activate()
// Best effort wait; mode/commands requests can still succeed without this.
await pWaitFor(() => host.client.isInitialized(), {
interval: 25,
timeout: 2_000,
}).catch(() => undefined)
return host
}
/**
* Send a request to the extension and wait for a matching response message.
* Returns `undefined` from `extract` to skip non-matching messages, or the
* parsed value to resolve the promise.
*/
function requestFromExtension<T>(
host: ExtensionHost,
requestType: WebviewMessage["type"],
extract: (message: Record<string, unknown>) => T | undefined,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false
const cleanup = () => {
clearTimeout(timeoutId)
host.off("extensionWebviewMessage", onMessage)
offError()
}
const finish = (fn: () => void) => {
if (settled) return
settled = true
cleanup()
fn()
}
const onMessage = (message: unknown) => {
if (!isRecord(message)) {
return
}
let result: T | undefined
try {
result = extract(message)
} catch (error) {
finish(() => reject(error instanceof Error ? error : new Error(String(error))))
return
}
if (result !== undefined) {
finish(() => resolve(result))
}
}
const offError = host.client.on("error", (error) => {
finish(() => reject(error))
})
const timeoutId = setTimeout(() => {
finish(() =>
reject(new Error(`Timed out waiting for ${requestType} response after ${REQUEST_TIMEOUT_MS}ms`)),
)
}, REQUEST_TIMEOUT_MS)
host.on("extensionWebviewMessage", onMessage)
host.sendToExtension({ type: requestType })
})
}
function requestCommands(host: ExtensionHost): Promise<CommandLike[]> {
return requestFromExtension(host, "requestCommands", (message) => {
if (message.type !== "commands") {
return undefined
}
return Array.isArray(message.commands) ? (message.commands as CommandLike[]) : []
})
}
function requestModes(host: ExtensionHost): Promise<ModeLike[]> {
return requestFromExtension(host, "requestModes", (message) => {
if (message.type !== "modes") {
return undefined
}
return Array.isArray(message.modes) ? (message.modes as ModeLike[]) : []
})
}
function requestOpenRouterModels(host: ExtensionHost): Promise<ModelRecord> {
return requestFromExtension(host, "requestRouterModels", (message) => {
if (message.type !== "routerModels") {
return undefined
}
const routerModels = isRecord(message.routerModels) ? message.routerModels : {}
const openRouterModels = routerModels.openrouter
return isRecord(openRouterModels) ? (openRouterModels as ModelRecord) : {}
})
}
async function withHostAndSignalHandlers<T>(
options: BaseListOptions,
hostOptions: ListHostOptions,
fn: (host: ExtensionHost) => Promise<T>,
): Promise<T> {
const host = await createListHost(options, hostOptions)
const shutdown = async (exitCode: number) => {
await host.dispose()
process.exit(exitCode)
}
const onSigint = () => void shutdown(130)
const onSigterm = () => void shutdown(143)
process.on("SIGINT", onSigint)
process.on("SIGTERM", onSigterm)
try {
return await fn(host)
} finally {
process.off("SIGINT", onSigint)
process.off("SIGTERM", onSigterm)
await host.dispose()
}
}
export async function listCommands(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
const commands = await requestCommands(host)
if (format === "json") {
outputJson({ commands })
return
}
outputCommandsText(commands)
})
}
export async function listModes(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
const modes = await requestModes(host)
if (format === "json") {
outputJson({ modes })
return
}
outputModesText(modes)
})
}
export async function listModels(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
await withHostAndSignalHandlers(options, { ephemeral: true }, async (host) => {
const models = await requestOpenRouterModels(host)
if (format === "json") {
outputJson({ models })
return
}
outputModelsText(models)
})
}
export async function listSessions(options: BaseListOptions): Promise<void> {
const format = parseFormat(options.format)
const workspacePath = resolveWorkspacePath(options.workspace)
const sessions = await readWorkspaceTaskSessions(workspacePath)
if (format === "json") {
outputJson({ workspace: workspacePath, sessions })
return
}
outputSessionsText(sessions)
}

View file

@ -3,50 +3,32 @@ import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { createElement } from "react" import { createElement } from "react"
import pWaitFor from "p-wait-for"
import { setLogger } from "@roo-code/vscode-shim" import { setLogger } from "@roo-code/vscode-shim"
import { import {
FlagOptions, FlagOptions,
isSupportedProvider, isSupportedProvider,
OnboardingProviderChoice,
supportedProviders, supportedProviders,
DEFAULT_FLAGS, DEFAULT_FLAGS,
REASONING_EFFORTS, REASONING_EFFORTS,
SDK_BASE_URL,
OutputFormat, OutputFormat,
} from "@/types/index.js" } from "@/types/index.js"
import { isValidOutputFormat } from "@/types/json-events.js" import { isValidOutputFormat } from "@/types/json-events.js"
import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
import { loadSettings } from "@/lib/storage/index.js" import { createClient } from "@/lib/sdk/index.js"
import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js" import { loadToken, loadSettings } from "@/lib/storage/index.js"
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
import { validateTerminalShellPath } from "@/lib/utils/shell.js" import { runOnboarding } from "@/lib/utils/onboarding.js"
import { getDefaultExtensionPath } from "@/lib/utils/extension.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js"
import { isValidSessionId } from "@/lib/utils/session-id.js"
import { VERSION } from "@/lib/utils/version.js" import { VERSION } from "@/lib/utils/version.js"
import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js"
import { isExpectedControlFlowError } from "./cancellation.js"
import { runStdinStreamMode } from "./stdin-stream.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000
const STREAM_RESUME_WAIT_TIMEOUT_MS = 2_000
async function bootstrapResumeForStdinStream(host: ExtensionHost, sessionId: string): Promise<void> {
host.sendToExtension({ type: "showTaskWithId", text: sessionId })
// Best-effort wait so early stdin "message" commands can target the resumed task.
await pWaitFor(() => host.client.hasActiveTask() || host.isWaitingForInput(), {
interval: 25,
timeout: STREAM_RESUME_WAIT_TIMEOUT_MS,
}).catch(() => undefined)
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { export async function run(promptArg: string | undefined, flagOptions: FlagOptions) {
setLogger({ setLogger({
@ -67,94 +49,31 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
prompt = fs.readFileSync(flagOptions.promptFile, "utf-8") prompt = fs.readFileSync(flagOptions.promptFile, "utf-8")
} }
const requestedSessionId = flagOptions.sessionId?.trim()
const requestedCreateSessionId = flagOptions.createWithSessionId?.trim()
const shouldContinueSession = flagOptions.continue
const isResumeRequested = Boolean(requestedSessionId || shouldContinueSession)
if (flagOptions.createWithSessionId !== undefined && !requestedCreateSessionId) {
console.error("[CLI] Error: --create-with-session-id requires a non-empty session id")
process.exit(1)
}
if (flagOptions.sessionId !== undefined && !requestedSessionId) {
console.error("[CLI] Error: --session-id requires a non-empty session id")
process.exit(1)
}
if (requestedCreateSessionId && !isValidSessionId(requestedCreateSessionId)) {
console.error("[CLI] Error: --create-with-session-id must be a valid UUID session id")
process.exit(1)
}
if (requestedSessionId && !isValidSessionId(requestedSessionId)) {
console.error("[CLI] Error: --session-id must be a valid UUID session id")
process.exit(1)
}
if (requestedCreateSessionId && isResumeRequested) {
console.error("[CLI] Error: cannot use --create-with-session-id with --session-id/--continue")
process.exit(1)
}
if (requestedSessionId && shouldContinueSession) {
console.error("[CLI] Error: cannot use --session-id with --continue")
process.exit(1)
}
if (isResumeRequested && prompt) {
console.error("[CLI] Error: cannot use prompt or --prompt-file with --session-id/--continue")
console.error("[CLI] Usage: roo [--session-id <session-id> | --continue] [options]")
process.exit(1)
}
// Options // Options
let rooToken = await loadToken()
const settings = await loadSettings() const settings = await loadSettings()
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const isTuiEnabled = !flagOptions.print && isTuiSupported const isTuiEnabled = !flagOptions.print && isTuiSupported
const isOnboardingEnabled = isTuiEnabled && !rooToken && !flagOptions.provider && !settings.provider
// Determine effective values: CLI flags > settings file > DEFAULT_FLAGS. // Determine effective values: CLI flags > settings file > DEFAULT_FLAGS.
const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode
const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model
const effectiveReasoningEffort = const effectiveReasoningEffort =
flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort
const effectiveProvider = flagOptions.provider ?? settings.provider ?? "openrouter" const effectiveProvider = flagOptions.provider ?? settings.provider ?? (rooToken ? "roo" : "openrouter")
const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd() const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd()
const legacyRequireApprovalFromSettings = const legacyRequireApprovalFromSettings =
settings.requireApproval ?? settings.requireApproval ??
(settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions) (settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions)
const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false
const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false
const rawConsecutiveMistakeLimit =
flagOptions.consecutiveMistakeLimit ?? settings.consecutiveMistakeLimit ?? DEFAULT_FLAGS.consecutiveMistakeLimit
const effectiveConsecutiveMistakeLimit = Number(rawConsecutiveMistakeLimit)
if (!Number.isInteger(effectiveConsecutiveMistakeLimit) || effectiveConsecutiveMistakeLimit < 0) {
console.error(
`[CLI] Error: Invalid consecutive mistake limit: ${rawConsecutiveMistakeLimit}; must be a non-negative integer`,
)
process.exit(1)
}
let terminalShell: string | undefined
if (flagOptions.terminalShell !== undefined) {
const validatedTerminalShell = await validateTerminalShellPath(flagOptions.terminalShell)
if (!validatedTerminalShell.valid) {
console.error(
`[CLI] Warning: ignoring --terminal-shell "${flagOptions.terminalShell}" (${validatedTerminalShell.reason})`,
)
} else {
terminalShell = validatedTerminalShell.shellPath
}
}
const extensionHostOptions: ExtensionHostOptions = { const extensionHostOptions: ExtensionHostOptions = {
mode: effectiveMode, mode: effectiveMode,
reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort, reasoningEffort: effectiveReasoningEffort === "unspecified" ? undefined : effectiveReasoningEffort,
consecutiveMistakeLimit: effectiveConsecutiveMistakeLimit,
user: null, user: null,
provider: effectiveProvider, provider: effectiveProvider,
model: effectiveModel, model: effectiveModel,
@ -165,7 +84,49 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
ephemeral: flagOptions.ephemeral, ephemeral: flagOptions.ephemeral,
debug: flagOptions.debug, debug: flagOptions.debug,
exitOnComplete: effectiveExitOnComplete, exitOnComplete: effectiveExitOnComplete,
terminalShell, }
// Roo Code Cloud Authentication
if (isOnboardingEnabled) {
let { onboardingProviderChoice } = settings
if (!onboardingProviderChoice) {
const { choice, token } = await runOnboarding()
onboardingProviderChoice = choice
rooToken = token ?? null
}
if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
extensionHostOptions.provider = "roo"
}
}
if (extensionHostOptions.provider === "roo") {
if (rooToken) {
try {
const client = createClient({ url: SDK_BASE_URL, authToken: rooToken })
const me = await client.auth.me.query()
if (me?.type !== "user") {
throw new Error("Invalid token")
}
extensionHostOptions.apiKey = rooToken
extensionHostOptions.user = me.user
} catch {
// If an explicit API key was provided via flag or env var, fall through
// to the general API key resolution below instead of exiting.
if (!flagOptions.apiKey && !getApiKeyFromEnv(extensionHostOptions.provider)) {
console.error("[CLI] Your Roo Code Router token is not valid.")
console.error("[CLI] Please run: roo auth login")
console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.")
process.exit(1)
}
}
}
// If no rooToken, fall through to the general API key resolution below
// which will check flagOptions.apiKey and ROO_API_KEY env var.
} }
// Validations // Validations
@ -183,8 +144,18 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
extensionHostOptions.apiKey || flagOptions.apiKey || getApiKeyFromEnv(extensionHostOptions.provider) extensionHostOptions.apiKey || flagOptions.apiKey || getApiKeyFromEnv(extensionHostOptions.provider)
if (!extensionHostOptions.apiKey) { if (!extensionHostOptions.apiKey) {
console.error(`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`) if (extensionHostOptions.provider === "roo") {
console.error(`[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`) console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
console.error("[CLI] Please run: roo auth login")
console.error("[CLI] Or use --api-key to provide your own API key.")
} else {
console.error(
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
)
console.error(
`[CLI] For ${extensionHostOptions.provider}, set ${getEnvVarName(extensionHostOptions.provider)}`,
)
}
process.exit(1) process.exit(1)
} }
@ -214,76 +185,15 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
// Output format only works with --print mode // Output format only works with --print mode
if (outputFormat !== "text" && !flagOptions.print && isTuiSupported) { if (outputFormat !== "text" && !flagOptions.print && isTuiSupported) {
console.error("[CLI] Error: --output-format requires --print mode") console.error("[CLI] Error: --output-format requires --print mode")
console.error("[CLI] Usage: roo --print --output-format json") console.error("[CLI] Usage: roo <prompt> --print --output-format json")
process.exit(1) process.exit(1)
} }
if (flagOptions.stdinPromptStream && !flagOptions.print) {
console.error("[CLI] Error: --stdin-prompt-stream requires --print mode")
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]")
process.exit(1)
}
if (flagOptions.signalOnlyExit && !flagOptions.stdinPromptStream) {
console.error("[CLI] Error: --signal-only-exit requires --stdin-prompt-stream")
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream --signal-only-exit")
process.exit(1)
}
if (flagOptions.stdinPromptStream && outputFormat !== "stream-json") {
console.error("[CLI] Error: --stdin-prompt-stream requires --output-format=stream-json")
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]")
process.exit(1)
}
if (flagOptions.stdinPromptStream && process.stdin.isTTY) {
console.error("[CLI] Error: --stdin-prompt-stream requires piped stdin")
console.error(
'[CLI] Example: printf \'{"command":"start","requestId":"1","prompt":"1+1=?"}\\n\' | roo --print --output-format stream-json --stdin-prompt-stream [options]',
)
process.exit(1)
}
if (flagOptions.stdinPromptStream && prompt) {
console.error("[CLI] Error: cannot use positional prompt or --prompt-file with --stdin-prompt-stream")
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]")
process.exit(1)
}
if (flagOptions.stdinPromptStream && requestedCreateSessionId) {
console.error("[CLI] Error: --create-with-session-id is not supported with --stdin-prompt-stream")
console.error('[CLI] Use per-request "taskId" in stdin start commands instead.')
process.exit(1)
}
const useStdinPromptStream = flagOptions.stdinPromptStream
let resolvedResumeSessionId: string | undefined
if (isResumeRequested) {
const workspaceSessions = await readWorkspaceTaskSessions(effectiveWorkspacePath)
try {
resolvedResumeSessionId = resolveWorkspaceResumeSessionId(workspaceSessions, requestedSessionId)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[CLI] Error: ${message}`)
process.exit(1)
}
}
if (!isTuiEnabled) { if (!isTuiEnabled) {
if (!prompt && !useStdinPromptStream && !isResumeRequested) { if (!prompt) {
if (flagOptions.print) { console.error("[CLI] Error: prompt is required in print mode")
console.error("[CLI] Error: no prompt provided") console.error("[CLI] Usage: roo <prompt> --print [options]")
console.error("[CLI] Usage: roo --print [options] <prompt>") console.error("[CLI] Run without -p for interactive mode")
console.error(
"[CLI] For stdin control mode: roo --print --output-format stream-json --stdin-prompt-stream [options]",
)
} else {
console.error("[CLI] Error: prompt is required in non-interactive mode")
console.error("[CLI] Usage: roo <prompt> [options]")
console.error("[CLI] Run without -p for interactive mode")
}
process.exit(1) process.exit(1)
} }
@ -303,9 +213,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
createElement(App, { createElement(App, {
...extensionHostOptions, ...extensionHostOptions,
initialPrompt: prompt, initialPrompt: prompt,
initialTaskId: requestedCreateSessionId,
initialSessionId: resolvedResumeSessionId,
continueSession: false,
version: VERSION, version: VERSION,
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts), createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}), }),
@ -323,172 +230,26 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
} }
} else { } else {
const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json" const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json"
const signalOnlyExit = flagOptions.signalOnlyExit
extensionHostOptions.disableOutput = useJsonOutput extensionHostOptions.disableOutput = useJsonOutput
const host = new ExtensionHost(extensionHostOptions) const host = new ExtensionHost(extensionHostOptions)
let streamRequestId: string | undefined
let keepAliveInterval: NodeJS.Timeout | undefined
let isShuttingDown = false
let hostDisposed = false
const jsonEmitter = useJsonOutput const jsonEmitter = useJsonOutput
? new JsonEventEmitter({ ? new JsonEventEmitter({ mode: outputFormat as "json" | "stream-json" })
mode: outputFormat as "json" | "stream-json",
requestIdProvider: () => streamRequestId,
})
: null : null
const emitRuntimeError = (error: Error, source?: string) => {
const errorMessage = source ? `${source}: ${error.message}` : error.message
if (useJsonOutput) {
const errorEvent = { type: "error", id: Date.now(), content: errorMessage }
process.stdout.write(JSON.stringify(errorEvent) + "\n")
return
}
console.error("[CLI] Error:", errorMessage)
console.error(error.stack)
}
const clearKeepAliveInterval = () => {
if (!keepAliveInterval) {
return
}
clearInterval(keepAliveInterval)
keepAliveInterval = undefined
}
const flushStdout = async () => {
try {
if (!process.stdout.writable || process.stdout.destroyed) {
return
}
await new Promise<void>((resolve, reject) => {
process.stdout.write("", (error?: Error | null) => {
if (error) {
reject(error)
return
}
resolve()
})
})
} catch {
// Best effort: shutdown should proceed even if stdout flush fails.
}
}
const ensureKeepAliveInterval = () => {
if (!signalOnlyExit || keepAliveInterval) {
return
}
keepAliveInterval = setInterval(() => {}, SIGNAL_ONLY_EXIT_KEEPALIVE_MS)
}
const disposeHost = async () => {
if (hostDisposed) {
return
}
hostDisposed = true
jsonEmitter?.detach()
await host.dispose()
}
const onSigint = () => {
void shutdown("SIGINT", 130)
}
const onSigterm = () => {
void shutdown("SIGTERM", 143)
}
const onUncaughtException = (error: Error) => {
if (
isExpectedControlFlowError(error, {
stdinStreamMode: useStdinPromptStream,
shuttingDown: isShuttingDown,
operation: "runtime",
})
) {
return
}
emitRuntimeError(error, "uncaughtException")
if (signalOnlyExit) {
return
}
void shutdown("uncaughtException", 1)
}
const onUnhandledRejection = (reason: unknown) => {
if (
isExpectedControlFlowError(reason, {
stdinStreamMode: useStdinPromptStream,
shuttingDown: isShuttingDown,
operation: "runtime",
})
) {
return
}
const error = normalizeError(reason)
emitRuntimeError(error, "unhandledRejection")
if (signalOnlyExit) {
return
}
void shutdown("unhandledRejection", 1)
}
const parkUntilSignal = async (reason: string): Promise<never> => {
ensureKeepAliveInterval()
if (!useJsonOutput) {
console.error(`[CLI] ${reason} (--signal-only-exit active; waiting for SIGINT/SIGTERM).`)
}
await new Promise<void>(() => {})
throw new Error("unreachable")
}
async function shutdown(signal: string, exitCode: number): Promise<void> { async function shutdown(signal: string, exitCode: number): Promise<void> {
if (isShuttingDown) {
return
}
isShuttingDown = true
process.off("SIGINT", onSigint)
process.off("SIGTERM", onSigterm)
process.off("uncaughtException", onUncaughtException)
process.off("unhandledRejection", onUnhandledRejection)
clearKeepAliveInterval()
if (!useJsonOutput) { if (!useJsonOutput) {
console.log(`\n[CLI] Received ${signal}, shutting down...`) console.log(`\n[CLI] Received ${signal}, shutting down...`)
} }
jsonEmitter?.detach()
await disposeHost() await host.dispose()
if (jsonEmitter) {
await jsonEmitter.flush()
}
await flushStdout()
process.exit(exitCode) process.exit(exitCode)
} }
process.on("SIGINT", onSigint) process.on("SIGINT", () => shutdown("SIGINT", 130))
process.on("SIGTERM", onSigterm) process.on("SIGTERM", () => shutdown("SIGTERM", 143))
process.on("uncaughtException", onUncaughtException)
process.on("unhandledRejection", onUnhandledRejection)
try { try {
await host.activate() await host.activate()
@ -497,61 +258,25 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
jsonEmitter.attachToClient(host.client) jsonEmitter.attachToClient(host.client)
} }
if (useStdinPromptStream) { await host.runTask(prompt!)
if (!jsonEmitter || outputFormat !== "stream-json") { jsonEmitter?.detach()
throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events") await host.dispose()
}
if (isResumeRequested) {
await bootstrapResumeForStdinStream(host, resolvedResumeSessionId!)
}
await runStdinStreamMode({
host,
jsonEmitter,
setStreamRequestId: (id) => {
streamRequestId = id
},
})
} else {
if (isResumeRequested) {
await host.resumeTask(resolvedResumeSessionId!)
} else {
await host.runTask(prompt!, requestedCreateSessionId)
}
}
await disposeHost()
if (jsonEmitter) {
await jsonEmitter.flush()
}
await flushStdout()
if (signalOnlyExit) {
await parkUntilSignal("Task loop completed")
}
process.off("SIGINT", onSigint)
process.off("SIGTERM", onSigterm)
process.off("uncaughtException", onUncaughtException)
process.off("unhandledRejection", onUnhandledRejection)
process.exit(0) process.exit(0)
} catch (error) { } catch (error) {
emitRuntimeError(normalizeError(error)) const errorMessage = error instanceof Error ? error.message : String(error)
await disposeHost()
if (jsonEmitter) {
await jsonEmitter.flush()
}
await flushStdout()
if (signalOnlyExit) { if (useJsonOutput) {
await parkUntilSignal("Task loop failed") const errorEvent = { type: "error", id: Date.now(), content: errorMessage }
process.stdout.write(JSON.stringify(errorEvent) + "\n")
} else {
console.error("[CLI] Error:", errorMessage)
if (error instanceof Error) {
console.error(error.stack)
}
} }
process.off("SIGINT", onSigint) jsonEmitter?.detach()
process.off("SIGTERM", onSigterm) await host.dispose()
process.off("uncaughtException", onUncaughtException)
process.off("unhandledRejection", onUnhandledRejection)
process.exit(1) process.exit(1)
} }
} }

View file

@ -1,977 +0,0 @@
import { createInterface } from "readline"
import { randomUUID } from "crypto"
import {
rooCliCommandNames,
type RooCliCommandName,
type RooCliInputCommand,
type RooCliStartCommand,
} from "@roo-code/types"
import { isRecord } from "@/lib/utils/guards.js"
import { isValidSessionId } from "@/lib/utils/session-id.js"
import { isCancellationLikeError, isExpectedControlFlowError, isNoActiveTaskLikeError } from "./cancellation.js"
import type { ExtensionHost } from "@/agent/index.js"
import type { JsonEventEmitter } from "@/agent/json-event-emitter.js"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type StdinStreamCommandName = RooCliCommandName
export type StdinStreamCommand = RooCliInputCommand
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
export const VALID_STDIN_COMMANDS = new Set<StdinStreamCommandName>(rooCliCommandNames)
export function parseStdinStreamCommand(line: string, lineNumber: number): StdinStreamCommand {
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
throw new Error(`stdin command line ${lineNumber}: invalid JSON`)
}
if (!isRecord(parsed)) {
throw new Error(`stdin command line ${lineNumber}: expected JSON object`)
}
const commandRaw = parsed.command
const requestIdRaw = parsed.requestId
if (typeof commandRaw !== "string") {
throw new Error(`stdin command line ${lineNumber}: missing string "command"`)
}
if (!VALID_STDIN_COMMANDS.has(commandRaw as StdinStreamCommandName)) {
throw new Error(
`stdin command line ${lineNumber}: unsupported command "${commandRaw}" (expected start|message|cancel|ping|shutdown)`,
)
}
if (typeof requestIdRaw !== "string" || requestIdRaw.trim().length === 0) {
throw new Error(`stdin command line ${lineNumber}: missing non-empty string "requestId"`)
}
const command = commandRaw as StdinStreamCommandName
const requestId = requestIdRaw.trim()
if (command === "start" || command === "message") {
const promptRaw = parsed.prompt
if (typeof promptRaw !== "string" || promptRaw.trim().length === 0) {
throw new Error(`stdin command line ${lineNumber}: "${command}" requires non-empty string "prompt"`)
}
const imagesRaw = parsed.images
let images: string[] | undefined
if (imagesRaw !== undefined) {
if (!Array.isArray(imagesRaw) || !imagesRaw.every((image) => typeof image === "string")) {
throw new Error(`stdin command line ${lineNumber}: "${command}" images must be an array of strings`)
}
images = imagesRaw
}
if (command === "start") {
const taskIdRaw = parsed.taskId
let taskId: string | undefined
if (taskIdRaw !== undefined) {
if (typeof taskIdRaw !== "string" || taskIdRaw.trim().length === 0) {
throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a non-empty string`)
}
taskId = taskIdRaw.trim()
if (!isValidSessionId(taskId)) {
throw new Error(`stdin command line ${lineNumber}: "start" taskId must be a valid UUID`)
}
}
if (isRecord(parsed.configuration)) {
return {
command,
requestId,
prompt: promptRaw,
...(taskId !== undefined ? { taskId } : {}),
...(images !== undefined ? { images } : {}),
configuration: parsed.configuration as RooCliStartCommand["configuration"],
}
}
return {
command,
requestId,
prompt: promptRaw,
...(taskId !== undefined ? { taskId } : {}),
...(images !== undefined ? { images } : {}),
}
}
return {
command,
requestId,
prompt: promptRaw,
...(images !== undefined ? { images } : {}),
}
}
return { command, requestId }
}
// ---------------------------------------------------------------------------
// NDJSON stdin reader
// ---------------------------------------------------------------------------
async function* readCommandsFromStdinNdjson(): AsyncGenerator<StdinStreamCommand> {
const lineReader = createInterface({
input: process.stdin,
crlfDelay: Infinity,
terminal: false,
})
let lineNumber = 0
try {
for await (const line of lineReader) {
lineNumber += 1
const trimmed = line.trim()
if (!trimmed) {
continue
}
yield parseStdinStreamCommand(trimmed, lineNumber)
}
} finally {
lineReader.close()
}
}
// ---------------------------------------------------------------------------
// Queue snapshot helpers
// ---------------------------------------------------------------------------
interface StreamQueueItem {
id: string
text?: string
imageCount: number
timestamp?: number
}
function normalizeQueueText(text: string | undefined): string | undefined {
if (!text) {
return undefined
}
const compact = text.replace(/\s+/g, " ").trim()
if (!compact) {
return undefined
}
return compact.length <= 180 ? compact : `${compact.slice(0, 177)}...`
}
function parseQueueSnapshot(rawQueue: unknown): StreamQueueItem[] | undefined {
if (!Array.isArray(rawQueue)) {
return undefined
}
const snapshot: StreamQueueItem[] = []
for (const entry of rawQueue) {
if (!isRecord(entry)) {
continue
}
const idRaw = entry.id
if (typeof idRaw !== "string" || idRaw.trim().length === 0) {
continue
}
const imagesRaw = entry.images
const timestampRaw = entry.timestamp
const imageCount = Array.isArray(imagesRaw) ? imagesRaw.length : 0
snapshot.push({
id: idRaw,
text: normalizeQueueText(typeof entry.text === "string" ? entry.text : undefined),
imageCount,
timestamp: typeof timestampRaw === "number" ? timestampRaw : undefined,
})
}
return snapshot
}
function areStringArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) {
return false
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false
}
}
return true
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
export interface StdinStreamModeOptions {
host: ExtensionHost
jsonEmitter: JsonEventEmitter
setStreamRequestId: (id: string | undefined) => void
}
const RESUME_ASKS = new Set(["resume_task", "resume_completed_task"])
const CANCEL_RECOVERY_WAIT_TIMEOUT_MS = 8_000
const CANCEL_RECOVERY_POLL_INTERVAL_MS = 100
const STDIN_EOF_RESUME_WAIT_TIMEOUT_MS = 2_000
const STDIN_EOF_POLL_INTERVAL_MS = 100
const STDIN_EOF_IDLE_ASKS = new Set(["completion_result", "resume_completed_task"])
const STDIN_EOF_IDLE_STABLE_POLLS = 2
const MESSAGE_AS_ASK_RESPONSE_ASKS = new Set([
"followup",
"tool",
"command",
"use_mcp_server",
"completion_result",
"resume_task",
"resume_completed_task",
"mistake_limit_reached",
])
export function shouldSendMessageAsAskResponse(waitingForInput: boolean, currentAsk: string | undefined): boolean {
return waitingForInput && typeof currentAsk === "string" && MESSAGE_AS_ASK_RESPONSE_ASKS.has(currentAsk)
}
function isResumableState(host: ExtensionHost): boolean {
const agentState = host.client.getAgentState()
return (
agentState.isWaitingForInput &&
typeof agentState.currentAsk === "string" &&
RESUME_ASKS.has(agentState.currentAsk)
)
}
async function waitForPostCancelRecovery(host: ExtensionHost): Promise<void> {
const deadline = Date.now() + CANCEL_RECOVERY_WAIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (isResumableState(host)) {
return
}
await new Promise((resolve) => setTimeout(resolve, CANCEL_RECOVERY_POLL_INTERVAL_MS))
}
}
async function waitForTaskProgressAfterStdinClosed(
host: ExtensionHost,
getQueueState: () => { hasSeenQueueState: boolean; queueDepth: number },
): Promise<void> {
while (host.client.hasActiveTask()) {
if (!host.isWaitingForInput()) {
await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS))
continue
}
const deadline = Date.now() + STDIN_EOF_RESUME_WAIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (!host.client.hasActiveTask() || !host.isWaitingForInput()) {
break
}
await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS))
}
if (host.client.hasActiveTask() && host.isWaitingForInput()) {
const currentAsk = host.client.getCurrentAsk()
const { hasSeenQueueState, queueDepth } = getQueueState()
// EOF is allowed when the task has reached an idle completion boundary and
// there is no queued user input waiting to be processed.
if (
hasSeenQueueState &&
queueDepth === 0 &&
typeof currentAsk === "string" &&
STDIN_EOF_IDLE_ASKS.has(currentAsk)
) {
let isStable = true
for (let i = 1; i < STDIN_EOF_IDLE_STABLE_POLLS; i++) {
await new Promise((resolve) => setTimeout(resolve, STDIN_EOF_POLL_INTERVAL_MS))
if (!host.client.hasActiveTask() || !host.isWaitingForInput()) {
isStable = false
break
}
const nextAsk = host.client.getCurrentAsk()
const nextQueueState = getQueueState()
if (
nextAsk !== currentAsk ||
!nextQueueState.hasSeenQueueState ||
nextQueueState.queueDepth !== 0
) {
isStable = false
break
}
}
if (isStable) {
return
}
}
throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`)
}
}
}
export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }: StdinStreamModeOptions) {
let hasReceivedStdinCommand = false
let shouldShutdown = false
let activeTaskPromise: Promise<void> | null = null
let fatalStreamError: Error | null = null
let activeRequestId: string | undefined
let activeTaskCommand: "start" | undefined
let latestTaskId: string | undefined
let cancelRequestedForActiveTask = false
let awaitingPostCancelRecovery = false
let hasSeenQueueState = false
let lastQueueDepth = 0
let lastQueueMessageIds: string[] = []
const pendingQueuedMessageRequestIds: string[] = []
const queueMessageRequestIdByMessageId = new Map<string, string>()
const assignRequestIdsToNewQueueMessages = (queueMessageIds: string[]) => {
for (const messageId of queueMessageIds) {
if (queueMessageRequestIdByMessageId.has(messageId)) {
continue
}
const requestId = pendingQueuedMessageRequestIds.shift()
if (!requestId) {
continue
}
queueMessageRequestIdByMessageId.set(messageId, requestId)
}
}
const promoteRequestIdForDequeuedMessages = (queueMessageIds: string[]) => {
if (lastQueueMessageIds.length === 0) {
return
}
const remainingIds = new Set(queueMessageIds)
for (const dequeuedMessageId of lastQueueMessageIds) {
if (remainingIds.has(dequeuedMessageId)) {
continue
}
const requestId = queueMessageRequestIdByMessageId.get(dequeuedMessageId)
if (requestId) {
setStreamRequestId(requestId)
}
queueMessageRequestIdByMessageId.delete(dequeuedMessageId)
}
}
const waitForPreviousTaskToSettle = async () => {
if (!activeTaskPromise) {
return
}
try {
await activeTaskPromise
} catch {
// Errors are emitted through control/error events.
}
}
const offClientError = host.client.on("error", (error) => {
if (
isExpectedControlFlowError(error, {
stdinStreamMode: true,
cancelRequested: cancelRequestedForActiveTask,
shuttingDown: shouldShutdown,
operation: "client",
})
) {
if (activeTaskCommand === "start" && (cancelRequestedForActiveTask || isCancellationLikeError(error))) {
jsonEmitter.emitControl({
subtype: "done",
requestId: activeRequestId,
command: "start",
taskId: latestTaskId,
content: "task cancelled",
code: "task_aborted",
success: false,
})
}
activeTaskCommand = undefined
activeRequestId = undefined
setStreamRequestId(undefined)
cancelRequestedForActiveTask = false
awaitingPostCancelRecovery = false
return
}
fatalStreamError = error
jsonEmitter.emitControl({
subtype: "error",
requestId: activeRequestId,
command: activeTaskCommand,
taskId: latestTaskId,
content: error.message,
code: "client_error",
success: false,
})
})
const onExtensionMessage = (message: {
type?: string
text?: unknown
state?: {
currentTaskId?: unknown
currentTaskItem?: { id?: unknown }
messageQueue?: unknown
}
}) => {
if (message.type === "commandExecutionStatus") {
if (typeof message.text !== "string") {
return
}
let parsedStatus: unknown
try {
parsedStatus = JSON.parse(message.text)
} catch {
return
}
if (!isRecord(parsedStatus) || typeof parsedStatus.status !== "string") {
return
}
if (parsedStatus.status === "output" && typeof parsedStatus.output === "string") {
jsonEmitter.emitCommandOutputChunk(parsedStatus.output)
return
}
if (parsedStatus.status === "exited") {
const exitCode =
parsedStatus.status === "exited" && typeof parsedStatus.exitCode === "number"
? parsedStatus.exitCode
: undefined
if (typeof parsedStatus.output === "string") {
jsonEmitter.emitCommandOutputChunk(parsedStatus.output)
}
jsonEmitter.markCommandOutputExited(exitCode)
return
}
if (parsedStatus.status === "timeout" || parsedStatus.status === "fallback") {
jsonEmitter.emitCommandOutputDone(undefined)
return
}
return
}
if (message.type !== "state") {
return
}
const currentTaskId = message.state?.currentTaskId ?? message.state?.currentTaskItem?.id
if (typeof currentTaskId === "string" && currentTaskId.trim().length > 0) {
latestTaskId = currentTaskId
}
const queueSnapshot = parseQueueSnapshot(message.state?.messageQueue)
if (!queueSnapshot) {
return
}
const queueDepth = queueSnapshot.length
const queueMessageIds = queueSnapshot.map((item) => item.id)
if (!hasSeenQueueState) {
assignRequestIdsToNewQueueMessages(queueMessageIds)
hasSeenQueueState = true
lastQueueDepth = queueDepth
lastQueueMessageIds = queueMessageIds
if (queueDepth === 0) {
return
}
jsonEmitter.emitQueue({
subtype: "snapshot",
taskId: latestTaskId,
content: `queue snapshot (${queueDepth} item${queueDepth === 1 ? "" : "s"})`,
queueDepth,
queue: queueSnapshot,
})
return
}
const depthChanged = queueDepth !== lastQueueDepth
const idsChanged = !areStringArraysEqual(queueMessageIds, lastQueueMessageIds)
if (!depthChanged && !idsChanged) {
return
}
promoteRequestIdForDequeuedMessages(queueMessageIds)
assignRequestIdsToNewQueueMessages(queueMessageIds)
const subtype: "enqueued" | "dequeued" | "drained" | "updated" = depthChanged
? queueDepth > lastQueueDepth
? "enqueued"
: queueDepth === 0
? "drained"
: "dequeued"
: "updated"
const content =
subtype === "drained"
? "queue drained"
: `queue ${subtype} (${queueDepth} item${queueDepth === 1 ? "" : "s"})`
jsonEmitter.emitQueue({
subtype,
taskId: latestTaskId,
content,
queueDepth,
queue: queueSnapshot,
})
lastQueueDepth = queueDepth
lastQueueMessageIds = queueMessageIds
}
host.on("extensionWebviewMessage", onExtensionMessage)
const offTaskCompleted = host.client.on("taskCompleted", (event) => {
if (activeTaskCommand === "start") {
const completionCode = event.success
? "task_completed"
: cancelRequestedForActiveTask
? "task_aborted"
: "task_failed"
jsonEmitter.emitControl({
subtype: "done",
requestId: activeRequestId,
command: "start",
taskId: latestTaskId,
content: event.success
? "task completed"
: cancelRequestedForActiveTask
? "task cancelled"
: "task failed",
code: completionCode,
success: event.success,
})
// If user messages were queued while the task was still running, shift
// event attribution to the oldest pending message request as soon as the
// task turn completes so prompt echo/user feedback events are tagged.
const oldestQueuedMessageId = lastQueueMessageIds[0]
const nextQueuedRequestId =
pendingQueuedMessageRequestIds[0] ??
(oldestQueuedMessageId ? queueMessageRequestIdByMessageId.get(oldestQueuedMessageId) : undefined)
if (nextQueuedRequestId) {
setStreamRequestId(nextQueuedRequestId)
}
activeTaskCommand = undefined
activeRequestId = undefined
cancelRequestedForActiveTask = false
}
})
try {
for await (const stdinCommand of readCommandsFromStdinNdjson()) {
hasReceivedStdinCommand = true
if (fatalStreamError) {
throw fatalStreamError
}
switch (stdinCommand.command) {
case "start": {
// A task can emit completion events before runTask() finalizers run.
// Wait for full settlement to avoid false "task_busy" on immediate next start.
// Safe from races: `for await` processes stdin commands serially, so no
// concurrent command can mutate state between the check and the await.
if (activeTaskPromise && !host.client.hasActiveTask()) {
await waitForPreviousTaskToSettle()
}
if (activeTaskPromise || host.client.hasActiveTask()) {
jsonEmitter.emitControl({
subtype: "error",
requestId: stdinCommand.requestId,
command: "start",
taskId: latestTaskId,
content: "cannot start a new task while another task is active",
code: "task_busy",
success: false,
})
break
}
activeRequestId = stdinCommand.requestId
activeTaskCommand = "start"
setStreamRequestId(stdinCommand.requestId)
latestTaskId = stdinCommand.taskId ?? randomUUID()
cancelRequestedForActiveTask = false
awaitingPostCancelRecovery = false
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
command: "start",
taskId: latestTaskId,
content: "starting task",
code: "accepted",
success: true,
})
// In CLI stdin-stream mode, default to the execa terminal provider so
// command output can be streamed deterministically. Explicit per-request
// config still wins.
const taskConfiguration = {
terminalShellIntegrationDisabled: true,
...(stdinCommand.configuration ?? {}),
}
activeTaskPromise = host
.runTask(stdinCommand.prompt, latestTaskId, taskConfiguration, stdinCommand.images)
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
if (
isExpectedControlFlowError(error, {
stdinStreamMode: true,
cancelRequested: cancelRequestedForActiveTask,
shuttingDown: shouldShutdown,
operation: "client",
})
) {
if (
activeTaskCommand === "start" &&
(cancelRequestedForActiveTask || isCancellationLikeError(error))
) {
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "start",
taskId: latestTaskId,
content: "task cancelled",
code: "task_aborted",
success: false,
})
}
activeTaskCommand = undefined
activeRequestId = undefined
setStreamRequestId(undefined)
cancelRequestedForActiveTask = false
awaitingPostCancelRecovery = false
return
}
fatalStreamError = error instanceof Error ? error : new Error(message)
activeTaskCommand = undefined
activeRequestId = undefined
setStreamRequestId(undefined)
jsonEmitter.emitControl({
subtype: "error",
requestId: stdinCommand.requestId,
command: "start",
taskId: latestTaskId,
content: message,
code: "task_error",
success: false,
})
})
.finally(() => {
activeTaskPromise = null
})
break
}
case "message": {
// If cancel was requested, wait briefly for the task to be rehydrated
// so message prompts don't race into the pre-cancel task instance.
if (awaitingPostCancelRecovery) {
await waitForPostCancelRecovery(host)
}
const wasResumable = isResumableState(host)
const currentAsk = host.client.getCurrentAsk()
const shouldSendAsAskResponse = shouldSendMessageAsAskResponse(host.isWaitingForInput(), currentAsk)
if (!host.client.hasActiveTask()) {
jsonEmitter.emitControl({
subtype: "error",
requestId: stdinCommand.requestId,
command: "message",
taskId: latestTaskId,
content: "no active task; send a start command first",
code: "no_active_task",
success: false,
})
break
}
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
command: "message",
taskId: latestTaskId,
content: "message accepted",
code: "accepted",
success: true,
})
if (shouldSendAsAskResponse) {
// Match webview behavior: if there is an active ask, route message directly as an ask response.
host.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: stdinCommand.prompt,
images: stdinCommand.images,
})
setStreamRequestId(stdinCommand.requestId)
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "message",
taskId: latestTaskId,
content: "message sent to current ask",
code: "responded",
success: true,
})
awaitingPostCancelRecovery = false
break
}
host.sendToExtension({
type: "queueMessage",
text: stdinCommand.prompt,
images: stdinCommand.images,
})
pendingQueuedMessageRequestIds.push(stdinCommand.requestId)
if (host.isWaitingForInput()) {
setStreamRequestId(stdinCommand.requestId)
}
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "message",
taskId: latestTaskId,
content: wasResumable ? "resume message queued" : "message queued",
code: wasResumable ? "resumed" : "queued",
success: true,
})
awaitingPostCancelRecovery = false
break
}
case "cancel": {
setStreamRequestId(stdinCommand.requestId)
const hasTaskInFlight = Boolean(
activeTaskPromise || activeTaskCommand === "start" || host.client.hasActiveTask(),
)
if (!hasTaskInFlight) {
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
command: "cancel",
taskId: latestTaskId,
content: "no active task to cancel",
code: "accepted",
success: true,
})
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "cancel",
taskId: latestTaskId,
content: "cancel ignored (no active task)",
code: "no_active_task",
success: true,
})
break
}
cancelRequestedForActiveTask = true
awaitingPostCancelRecovery = true
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
command: "cancel",
taskId: latestTaskId,
content: host.client.hasActiveTask() ? "cancel requested" : "cancel requested (task starting)",
code: "accepted",
success: true,
})
try {
host.client.cancelTask()
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "cancel",
taskId: latestTaskId,
content: "cancel signal sent",
code: "cancel_requested",
success: true,
})
} catch (error) {
if (
isExpectedControlFlowError(error, {
stdinStreamMode: true,
cancelRequested: true,
shuttingDown: shouldShutdown,
operation: "cancel",
})
) {
const noActiveTask = isNoActiveTaskLikeError(error)
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "cancel",
taskId: latestTaskId,
content: noActiveTask ? "cancel ignored (task already settled)" : "cancel handled",
code: noActiveTask ? "no_active_task" : "cancel_requested",
success: true,
})
if (noActiveTask) {
awaitingPostCancelRecovery = false
}
cancelRequestedForActiveTask = false
} else {
const message = error instanceof Error ? error.message : String(error)
jsonEmitter.emitControl({
subtype: "error",
requestId: stdinCommand.requestId,
command: "cancel",
taskId: latestTaskId,
content: message,
code: "cancel_error",
success: false,
})
}
}
break
}
case "ping":
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
command: "ping",
taskId: latestTaskId,
content: "pong",
code: "accepted",
success: true,
})
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "ping",
taskId: latestTaskId,
content: "pong",
code: "pong",
success: true,
})
break
case "shutdown":
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
command: "shutdown",
taskId: latestTaskId,
content: "shutdown requested",
code: "accepted",
success: true,
})
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "shutdown",
taskId: latestTaskId,
content: "shutting down process",
code: "shutdown_requested",
success: true,
})
shouldShutdown = true
break
}
if (shouldShutdown) {
break
}
}
if (!hasReceivedStdinCommand) {
throw new Error("no stdin command provided")
}
if (shouldShutdown && host.client.hasActiveTask()) {
host.client.cancelTask()
}
if (!shouldShutdown) {
if (activeTaskPromise) {
await activeTaskPromise
} else if (host.client.hasActiveTask()) {
await waitForTaskProgressAfterStdinClosed(host, () => ({
hasSeenQueueState,
queueDepth: lastQueueDepth,
}))
}
}
} finally {
offClientError()
host.off("extensionWebviewMessage", onExtensionMessage)
offTaskCompleted()
}
}

View file

@ -1,155 +0,0 @@
import { spawn } from "child_process"
import { VERSION } from "@/lib/utils/version.js"
import { isRecord } from "@/lib/utils/guards.js"
const RELEASES_URL = "https://api.github.com/repos/RooCodeInc/Roo-Code/releases?per_page=100"
export const INSTALL_SCRIPT_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh"
export interface UpgradeOptions {
currentVersion?: string
fetchImpl?: typeof fetch
runInstaller?: () => Promise<void>
}
function parseVersion(version: string): number[] {
const cleaned = version
.trim()
.replace(/^cli-v/, "")
.replace(/^v/, "")
const core = cleaned.split("+", 1)[0]?.split("-", 1)[0]
if (!core) {
throw new Error(`Invalid version: ${version}`)
}
const parts = core.split(".")
if (parts.length === 0) {
throw new Error(`Invalid version: ${version}`)
}
return parts.map((part) => {
if (!/^\d+$/.test(part)) {
throw new Error(`Invalid version: ${version}`)
}
return Number.parseInt(part, 10)
})
}
/**
* Returns:
* - 1 when `a > b`
* - 0 when `a === b`
* - -1 when `a < b`
*/
export function compareVersions(a: string, b: string): number {
const aParts = parseVersion(a)
const bParts = parseVersion(b)
const maxLength = Math.max(aParts.length, bParts.length)
for (let i = 0; i < maxLength; i++) {
const aPart = aParts[i] ?? 0
const bPart = bParts[i] ?? 0
if (aPart > bPart) {
return 1
}
if (aPart < bPart) {
return -1
}
}
return 0
}
export async function getLatestCliVersion(fetchImpl: typeof fetch = fetch): Promise<string> {
const response = await fetchImpl(RELEASES_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "roo-cli",
},
})
if (!response.ok) {
throw new Error(`Failed to check latest version (HTTP ${response.status})`)
}
const releases = await response.json()
if (!Array.isArray(releases)) {
throw new Error("Invalid release response from GitHub.")
}
let latestVersion: string | undefined
for (const release of releases) {
if (!isRecord(release)) {
continue
}
const tagName = release.tag_name
if (typeof tagName === "string" && tagName.startsWith("cli-v")) {
const candidate = tagName.slice("cli-v".length)
try {
if (!latestVersion || compareVersions(candidate, latestVersion) > 0) {
latestVersion = candidate
}
} catch {
// Ignore malformed CLI tags and keep scanning other releases.
}
}
}
if (latestVersion) {
return latestVersion
}
throw new Error("Could not determine the latest CLI release version.")
}
export function runUpgradeInstaller(version?: string, spawnImpl: typeof spawn = spawn): Promise<void> {
return new Promise((resolve, reject) => {
const env = version ? { ...process.env, ROO_VERSION: version } : process.env
const child = spawnImpl("sh", ["-c", INSTALL_SCRIPT_COMMAND], { stdio: "inherit", env })
child.once("error", (error) => {
reject(error)
})
child.once("close", (code, signal) => {
if (code === 0) {
resolve()
return
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`
reject(new Error(`Upgrade installer failed (${reason}).`))
})
})
}
export async function upgrade(options: UpgradeOptions = {}): Promise<void> {
const currentVersion = options.currentVersion ?? VERSION
const fetchImpl = options.fetchImpl ?? fetch
const runInstaller = options.runInstaller
console.log(`Current version: ${currentVersion}`)
const latestVersion = await getLatestCliVersion(fetchImpl)
console.log(`Latest version: ${latestVersion}`)
if (compareVersions(latestVersion, currentVersion) <= 0) {
console.log("Roo CLI is already up to date.")
return
}
console.log(`Upgrading Roo CLI from ${currentVersion} to ${latestVersion}...`)
if (runInstaller) {
await runInstaller()
} else {
await runUpgradeInstaller(latestVersion)
}
console.log("✓ Upgrade completed.")
}

View file

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

View file

@ -2,7 +2,7 @@ import { Command } from "commander"
import { DEFAULT_FLAGS } from "@/types/constants.js" import { DEFAULT_FLAGS } from "@/types/constants.js"
import { VERSION } from "@/lib/utils/version.js" import { VERSION } from "@/lib/utils/version.js"
import { run, listCommands, listModes, listModels, listSessions, upgrade } from "@/commands/index.js" import { run, login, logout, status } from "@/commands/index.js"
const program = new Command() const program = new Command()
@ -10,45 +10,24 @@ program
.name("roo") .name("roo")
.description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output") .description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output")
.version(VERSION) .version(VERSION)
.enablePositionalOptions()
.passThroughOptions()
program program
.argument("[prompt]", "Your prompt") .argument("[prompt]", "Your prompt")
.option("--prompt-file <path>", "Read prompt from a file instead of command line argument") .option("--prompt-file <path>", "Read prompt from a file instead of command line argument")
.option("--create-with-session-id <session-id>", "Create a new task with a specific session ID (must be a UUID)")
.option("--session-id <session-id>", "Resume a specific task by session ID")
.option("-c, --continue", "Resume the most recent task in the current workspace", false)
.option("-w, --workspace <path>", "Workspace directory path (defaults to current working directory)") .option("-w, --workspace <path>", "Workspace directory path (defaults to current working directory)")
.option("-p, --print", "Print response and exit (non-interactive mode)", false) .option("-p, --print", "Print response and exit (non-interactive mode)", false)
.option(
"--stdin-prompt-stream",
"Read NDJSON commands from stdin (requires --print and --output-format stream-json)",
false,
)
.option(
"--signal-only-exit",
"Do not exit from normal completion/errors; only terminate on SIGINT/SIGTERM (intended for stdin stream harnesses)",
false,
)
.option("-e, --extension <path>", "Path to the extension bundle directory") .option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false) .option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
.option("-a, --require-approval", "Require manual approval for actions", false) .option("-a, --require-approval", "Require manual approval for actions", false)
.option("-k, --api-key <key>", "API key for the LLM provider") .option("-k, --api-key <key>", "API key for the LLM provider")
.option("--provider <provider>", "API provider (anthropic, openai, openrouter, etc.)") .option("--provider <provider>", "API provider (roo, anthropic, openai, openrouter, etc.)")
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model) .option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
.option("--mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode) .option("--mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
.option("--terminal-shell <path>", "Absolute path to shell executable for inline terminal commands")
.option( .option(
"-r, --reasoning-effort <effort>", "-r, --reasoning-effort <effort>",
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULT_FLAGS.reasoningEffort, DEFAULT_FLAGS.reasoningEffort,
) )
.option(
"--consecutive-mistake-limit <limit>",
"Consecutive error/repetition limit before guidance prompt (0 disables the limit)",
(value) => Number.parseInt(value, 10),
)
.option("--exit-on-error", "Exit on API request errors instead of retrying", false) .option("--exit-on-error", "Exit on API request errors instead of retrying", false)
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false) .option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
.option("--oneshot", "Exit upon task completion", false) .option("--oneshot", "Exit upon task completion", false)
@ -59,71 +38,33 @@ program
) )
.action(run) .action(run)
const listCommand = program const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
.command("list")
.description("List commands, modes, models, or sessions")
.enablePositionalOptions()
.passThroughOptions()
const applyListOptions = (command: Command) => authCommand
command .command("login")
.option("-w, --workspace <path>", "Workspace directory path (defaults to current working directory)") .description("Authenticate with Roo Code Cloud")
.option("-e, --extension <path>", "Path to the extension bundle directory") .option("-v, --verbose", "Enable verbose output", false)
.option("-k, --api-key <key>", "API key for the LLM provider") .action(async (options: { verbose: boolean }) => {
.option("--format <format>", 'Output format: "json" (default) or "text"', "json") const result = await login({ verbose: options.verbose })
.option("-d, --debug", "Enable debug output", false) process.exit(result.success ? 0 : 1)
})
const runListAction = async (action: () => Promise<void>) => { authCommand
try { .command("logout")
await action() .description("Log out from Roo Code Cloud")
process.exit(0) .option("-v, --verbose", "Enable verbose output", false)
} catch (error) { .action(async (options: { verbose: boolean }) => {
const message = error instanceof Error ? error.message : String(error) const result = await logout({ verbose: options.verbose })
console.error(`[CLI] Error: ${message}`) process.exit(result.success ? 0 : 1)
process.exit(1) })
}
}
const runUpgradeAction = async (action: () => Promise<void>) => { authCommand
try { .command("status")
await action() .description("Show authentication status")
process.exit(0) .option("-v, --verbose", "Enable verbose output", false)
} catch (error) { .action(async (options: { verbose: boolean }) => {
const message = error instanceof Error ? error.message : String(error) const result = await status({ verbose: options.verbose })
console.error(`[CLI] Error: ${message}`) process.exit(result.authenticated ? 0 : 1)
process.exit(1)
}
}
applyListOptions(listCommand.command("commands").description("List available slash commands")).action(
async (options: Parameters<typeof listCommands>[0]) => {
await runListAction(() => listCommands(options))
},
)
applyListOptions(listCommand.command("modes").description("List available modes")).action(
async (options: Parameters<typeof listModes>[0]) => {
await runListAction(() => listModes(options))
},
)
applyListOptions(listCommand.command("models").description("List available models")).action(
async (options: Parameters<typeof listModels>[0]) => {
await runListAction(() => listModels(options))
},
)
applyListOptions(listCommand.command("sessions").description("List task sessions")).action(
async (options: Parameters<typeof listSessions>[0]) => {
await runListAction(() => listSessions(options))
},
)
program
.command("upgrade")
.description("Upgrade Roo Code CLI to the latest version")
.action(async () => {
await runUpgradeAction(() => upgrade())
}) })
program.parse() program.parse()

View file

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

View file

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

View file

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

View file

@ -51,7 +51,7 @@ describe("Settings Storage", () => {
it("should load saved settings", async () => { it("should load saved settings", async () => {
const settingsData = { const settingsData = {
onboardingProviderChoice: OnboardingProviderChoice.Byok, onboardingProviderChoice: OnboardingProviderChoice.Roo,
mode: "architect", mode: "architect",
provider: "anthropic" as const, provider: "anthropic" as const,
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
@ -105,7 +105,6 @@ describe("Settings Storage", () => {
provider: "anthropic" as const, provider: "anthropic" as const,
model: "claude-opus-4.6", model: "claude-opus-4.6",
reasoningEffort: "medium" as const, reasoningEffort: "medium" as const,
consecutiveMistakeLimit: 5,
}) })
const savedData = await fs.readFile(expectedSettingsFile, "utf-8") const savedData = await fs.readFile(expectedSettingsFile, "utf-8")
@ -115,7 +114,6 @@ describe("Settings Storage", () => {
expect(settings.provider).toBe("anthropic") expect(settings.provider).toBe("anthropic")
expect(settings.model).toBe("claude-opus-4.6") expect(settings.model).toBe("claude-opus-4.6")
expect(settings.reasoningEffort).toBe("medium") expect(settings.reasoningEffort).toBe("medium")
expect(settings.consecutiveMistakeLimit).toBe(5)
}) })
it("should create config directory if it doesn't exist", async () => { it("should create config directory if it doesn't exist", async () => {
@ -138,7 +136,7 @@ describe("Settings Storage", () => {
describe("resetOnboarding", () => { describe("resetOnboarding", () => {
it("should reset onboarding provider choice", async () => { it("should reset onboarding provider choice", async () => {
await saveSettings({ onboardingProviderChoice: OnboardingProviderChoice.Byok }) await saveSettings({ onboardingProviderChoice: OnboardingProviderChoice.Roo })
await resetOnboarding() await resetOnboarding()
@ -170,7 +168,6 @@ describe("Settings Storage", () => {
provider: "openai-native" as const, provider: "openai-native" as const,
model: "gpt-4o", model: "gpt-4o",
reasoningEffort: "low" as const, reasoningEffort: "low" as const,
consecutiveMistakeLimit: 7,
} }
await saveSettings(defaultSettings) await saveSettings(defaultSettings)
@ -180,14 +177,6 @@ describe("Settings Storage", () => {
expect(loaded.provider).toBe("openai-native") expect(loaded.provider).toBe("openai-native")
expect(loaded.model).toBe("gpt-4o") expect(loaded.model).toBe("gpt-4o")
expect(loaded.reasoningEffort).toBe("low") expect(loaded.reasoningEffort).toBe("low")
expect(loaded.consecutiveMistakeLimit).toBe(7)
})
it("should support consecutiveMistakeLimit setting", async () => {
await saveSettings({ consecutiveMistakeLimit: 0 })
const loaded = await loadSettings()
expect(loaded.consecutiveMistakeLimit).toBe(0)
}) })
it("should support requireApproval setting", async () => { it("should support requireApproval setting", async () => {
@ -229,7 +218,6 @@ describe("Settings Storage", () => {
provider: "anthropic" as const, provider: "anthropic" as const,
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
reasoningEffort: "high" as const, reasoningEffort: "high" as const,
consecutiveMistakeLimit: 9,
requireApproval: true, requireApproval: true,
oneshot: true, oneshot: true,
} }
@ -241,7 +229,6 @@ describe("Settings Storage", () => {
expect(loaded.provider).toBe("anthropic") expect(loaded.provider).toBe("anthropic")
expect(loaded.model).toBe("claude-sonnet-4-20250514") expect(loaded.model).toBe("claude-sonnet-4-20250514")
expect(loaded.reasoningEffort).toBe("high") expect(loaded.reasoningEffort).toBe("high")
expect(loaded.consecutiveMistakeLimit).toBe(9)
expect(loaded.requireApproval).toBe(true) expect(loaded.requireApproval).toBe(true)
expect(loaded.oneshot).toBe(true) expect(loaded.oneshot).toBe(true)
}) })

View file

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

View file

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

View file

@ -1,75 +0,0 @@
import { readTaskSessionsFromStoragePath } from "@roo-code/core/cli"
import {
filterSessionsForWorkspace,
getDefaultCliTaskStoragePath,
readWorkspaceTaskSessions,
resolveWorkspaceResumeSessionId,
} from "../index.js"
vi.mock("@roo-code/core/cli", async (importOriginal) => {
const actual = await importOriginal<typeof import("@roo-code/core/cli")>()
return {
...actual,
readTaskSessionsFromStoragePath: vi.fn(),
}
})
describe("task history workspace helpers", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("filters sessions to the current workspace and sorts newest first", () => {
const result = filterSessionsForWorkspace(
[
{ id: "a", task: "A", ts: 10, workspace: "/workspace/project" },
{ id: "b", task: "B", ts: 30, workspace: "/workspace/project/" },
{ id: "c", task: "C", ts: 20, workspace: "/workspace/other" },
{ id: "d", task: "D", ts: 40 },
],
"/workspace/project",
)
expect(result.map((session) => session.id)).toEqual(["b", "a"])
})
it("reads from storage path and applies workspace filtering", async () => {
vi.mocked(readTaskSessionsFromStoragePath).mockResolvedValue([
{ id: "a", task: "A", ts: 10, workspace: "/workspace/project" },
{ id: "b", task: "B", ts: 30, workspace: "/workspace/other" },
])
const result = await readWorkspaceTaskSessions("/workspace/project", "/custom/storage")
expect(readTaskSessionsFromStoragePath).toHaveBeenCalledWith("/custom/storage")
expect(result).toEqual([{ id: "a", task: "A", ts: 10, workspace: "/workspace/project" }])
})
it("returns the expected default CLI storage path", () => {
expect(getDefaultCliTaskStoragePath()).toContain(".vscode-mock")
expect(getDefaultCliTaskStoragePath()).toContain("global-storage")
})
it("resolves explicit session id only when it exists in current workspace sessions", () => {
const sessions = [
{ id: "a", task: "A", ts: 10, workspace: "/workspace/project" },
{ id: "b", task: "B", ts: 20, workspace: "/workspace/project" },
]
expect(resolveWorkspaceResumeSessionId(sessions, "a")).toBe("a")
expect(() => resolveWorkspaceResumeSessionId(sessions, "missing")).toThrow(
"Session not found in current workspace",
)
})
it("resolves continue to most recent session and errors when no sessions exist", () => {
const sessions = [
{ id: "newer", task: "Newer", ts: 30, workspace: "/workspace/project" },
{ id: "older", task: "Older", ts: 10, workspace: "/workspace/project" },
]
expect(resolveWorkspaceResumeSessionId(sessions)).toBe("newer")
expect(() => resolveWorkspaceResumeSessionId([])).toThrow("No previous tasks found to continue")
})
})

View file

@ -1,44 +0,0 @@
import os from "os"
import path from "path"
import { readTaskSessionsFromStoragePath, type TaskSessionEntry } from "@roo-code/core/cli"
import { arePathsEqual } from "@/lib/utils/path.js"
const DEFAULT_CLI_TASK_STORAGE_PATH = path.join(os.homedir(), ".vscode-mock", "global-storage")
export function getDefaultCliTaskStoragePath(): string {
return DEFAULT_CLI_TASK_STORAGE_PATH
}
export function filterSessionsForWorkspace(sessions: TaskSessionEntry[], workspacePath: string): TaskSessionEntry[] {
return sessions
.filter((session) => typeof session.workspace === "string" && arePathsEqual(session.workspace, workspacePath))
.sort((a, b) => b.ts - a.ts)
}
export async function readWorkspaceTaskSessions(
workspacePath: string,
storagePath = DEFAULT_CLI_TASK_STORAGE_PATH,
): Promise<TaskSessionEntry[]> {
const sessions = await readTaskSessionsFromStoragePath(storagePath)
return filterSessionsForWorkspace(sessions, workspacePath)
}
export function resolveWorkspaceResumeSessionId(sessions: TaskSessionEntry[], requestedSessionId?: string): string {
if (requestedSessionId) {
const hasRequestedSession = sessions.some((session) => session.id === requestedSessionId)
if (!hasRequestedSession) {
throw new Error(`Session not found in current workspace: ${requestedSessionId}`)
}
return requestedSessionId
}
const mostRecentSessionId = sessions[0]?.id
if (!mostRecentSessionId) {
throw new Error("No previous tasks found to continue in this workspace.")
}
return mostRecentSessionId
}

View file

@ -1,27 +0,0 @@
import { isRecord } from "../guards.js"
describe("isRecord", () => {
it("returns true for plain objects", () => {
expect(isRecord({})).toBe(true)
expect(isRecord({ a: 1 })).toBe(true)
})
it("returns true for arrays (arrays are objects)", () => {
expect(isRecord([])).toBe(true)
})
it("returns false for null", () => {
expect(isRecord(null)).toBe(false)
})
it("returns false for undefined", () => {
expect(isRecord(undefined)).toBe(false)
})
it("returns false for primitives", () => {
expect(isRecord("string")).toBe(false)
expect(isRecord(42)).toBe(false)
expect(isRecord(true)).toBe(false)
expect(isRecord(Symbol("s"))).toBe(false)
})
})

View file

@ -1,54 +0,0 @@
import fs from "fs/promises"
import { validateTerminalShellPath } from "../shell.js"
vi.mock("fs/promises", () => ({
default: {
access: vi.fn(),
stat: vi.fn(),
},
}))
describe("validateTerminalShellPath", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(fs.access).mockResolvedValue(undefined)
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
} as unknown as Awaited<ReturnType<typeof fs.stat>>)
})
it("returns invalid for an empty path", async () => {
const result = await validateTerminalShellPath(" ")
expect(result).toEqual({ valid: false, reason: "shell path cannot be empty" })
})
it("returns invalid for a relative path", async () => {
const result = await validateTerminalShellPath("bin/bash")
expect(result).toEqual({ valid: false, reason: "shell path must be absolute" })
})
it("returns valid for an absolute executable path", async () => {
const result = await validateTerminalShellPath("/bin/bash")
expect(result).toEqual({ valid: true, shellPath: "/bin/bash" })
})
it("returns invalid when the shell path cannot be accessed", async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error("ENOENT"))
const result = await validateTerminalShellPath("/missing/shell")
expect(result.valid).toBe(false)
if (!result.valid) {
expect(result.reason).toContain("shell path")
}
})
it("returns invalid when the shell path points to a directory", async () => {
vi.mocked(fs.stat).mockResolvedValueOnce({
isFile: () => false,
} as unknown as Awaited<ReturnType<typeof fs.stat>>)
const result = await validateTerminalShellPath("/bin")
expect(result).toEqual({ valid: false, reason: "shell path must point to a file" })
})
})

View file

@ -46,8 +46,6 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined {
return config.openAiModelId return config.openAiModelId
case "requesty": case "requesty":
return config.requestyModelId return config.requestyModelId
case "unbound":
return config.unboundModelId
case "litellm": case "litellm":
return config.litellmModelId return config.litellmModelId
case "vercel-ai-gateway": case "vercel-ai-gateway":

View file

@ -1,3 +0,0 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View file

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

View file

@ -8,6 +8,7 @@ const envVarMap: Record<SupportedProvider, string> = {
gemini: "GOOGLE_API_KEY", gemini: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY", "vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
roo: "ROO_API_KEY",
} }
export function getEnvVarName(provider: SupportedProvider): string { export function getEnvVarName(provider: SupportedProvider): string {
@ -47,6 +48,10 @@ export function getProviderSettings(
if (apiKey) config.vercelAiGatewayApiKey = apiKey if (apiKey) config.vercelAiGatewayApiKey = apiKey
if (model) config.vercelAiGatewayModelId = model if (model) config.vercelAiGatewayModelId = model
break break
case "roo":
if (apiKey) config.rooApiKey = apiKey
if (model) config.apiModelId = model
break
default: default:
if (apiKey) config.apiKey = apiKey if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model if (model) config.apiModelId = model

View file

@ -1,5 +0,0 @@
const SESSION_ID_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function isValidSessionId(value: string): boolean {
return SESSION_ID_UUID_PATTERN.test(value)
}

View file

@ -1,47 +0,0 @@
import fs from "fs/promises"
import { constants as fsConstants } from "fs"
import path from "path"
export type TerminalShellValidationResult =
| {
valid: true
shellPath: string
}
| {
valid: false
reason: string
}
export async function validateTerminalShellPath(rawShellPath: string): Promise<TerminalShellValidationResult> {
const shellPath = rawShellPath.trim()
if (!shellPath) {
return { valid: false, reason: "shell path cannot be empty" }
}
if (!path.isAbsolute(shellPath)) {
return { valid: false, reason: "shell path must be absolute" }
}
try {
const stats = await fs.stat(shellPath)
if (!stats.isFile()) {
return { valid: false, reason: "shell path must point to a file" }
}
if (process.platform !== "win32") {
await fs.access(shellPath, fsConstants.X_OK)
}
} catch {
return {
valid: false,
reason:
process.platform === "win32"
? "shell path does not exist or is not a file"
: "shell path does not exist, is not a file, or is not executable",
}
}
return { valid: true, shellPath }
}

View file

@ -4,7 +4,6 @@ export const DEFAULT_FLAGS = {
mode: "code", mode: "code",
reasoningEffort: "medium" as const, reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.6", model: "anthropic/claude-opus-4.6",
consecutiveMistakeLimit: 10,
} }
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
@ -21,3 +20,7 @@ export const ASCII_ROO = ` _,' ___
\\,\\ / \\\\ \\,\\ / \\\\
// \\\\ // \\\\
,/' \`\\_,` ,/' \`\\_,`
export const AUTH_BASE_URL = process.env.ROO_AUTH_BASE_URL ?? "https://app.roocode.com"
export const SDK_BASE_URL = process.env.ROO_SDK_BASE_URL ?? "https://cloud-api.roocode.com"

View file

@ -1,15 +1,3 @@
import {
rooCliOutputFormats,
type RooCliCost,
type RooCliEventType,
type RooCliFinalOutput,
type RooCliOutputFormat,
type RooCliQueueItem,
type RooCliStreamEvent,
type RooCliToolResult,
type RooCliToolUse,
} from "@roo-code/types"
/** /**
* JSON Event Types for Structured CLI Output * JSON Event Types for Structured CLI Output
* *
@ -26,9 +14,9 @@ import {
/** /**
* Output format options for the CLI. * Output format options for the CLI.
*/ */
export const OUTPUT_FORMATS = rooCliOutputFormats export const OUTPUT_FORMATS = ["text", "json", "stream-json"] as const
export type OutputFormat = RooCliOutputFormat export type OutputFormat = (typeof OUTPUT_FORMATS)[number]
export function isValidOutputFormat(format: string): format is OutputFormat { export function isValidOutputFormat(format: string): format is OutputFormat {
return (OUTPUT_FORMATS as readonly string[]).includes(format) return (OUTPUT_FORMATS as readonly string[]).includes(format)
@ -37,24 +25,53 @@ export function isValidOutputFormat(format: string): format is OutputFormat {
/** /**
* Event type discriminators for JSON output. * Event type discriminators for JSON output.
*/ */
export type JsonEventType = RooCliEventType export type JsonEventType =
| "system" // System messages (init, ready, shutdown)
export type JsonEventQueueItem = RooCliQueueItem | "assistant" // Assistant text messages
| "user" // User messages (echoed input)
| "tool_use" // Tool invocations (file ops, commands, browser, MCP)
| "tool_result" // Results from tool execution
| "thinking" // Reasoning/thinking content
| "error" // Errors
| "result" // Final task result
/** /**
* Tool use information for tool_use events. * Tool use information for tool_use events.
*/ */
export type JsonEventToolUse = RooCliToolUse export interface JsonEventToolUse {
/** Tool name (e.g., "read_file", "write_to_file", "execute_command") */
name: string
/** Tool input parameters */
input?: Record<string, unknown>
}
/** /**
* Tool result information for tool_result events. * Tool result information for tool_result events.
*/ */
export type JsonEventToolResult = RooCliToolResult export interface JsonEventToolResult {
/** Tool name that produced this result */
name: string
/** Tool output (for successful execution) */
output?: string
/** Error message (for failed execution) */
error?: string
}
/** /**
* Cost and token usage information. * Cost and token usage information.
*/ */
export type JsonEventCost = RooCliCost export interface JsonEventCost {
/** Total cost in USD */
totalCost?: number
/** Input tokens used */
inputTokens?: number
/** Output tokens generated */
outputTokens?: number
/** Cache write tokens */
cacheWrites?: number
/** Cache read tokens */
cacheReads?: number
}
/** /**
* Base JSON event structure. * Base JSON event structure.
@ -64,35 +81,17 @@ export type JsonEventCost = RooCliCost
* - Each delta includes `id` for easy correlation * - Each delta includes `id` for easy correlation
* - Final message has `done: true` * - Final message has `done: true`
*/ */
export type JsonEvent = RooCliStreamEvent & { export interface JsonEvent {
/** Event type discriminator */ /** Event type discriminator */
type: JsonEventType type: JsonEventType
/** Protocol schema version (included on system.init) */
schemaVersion?: number
/** Transport protocol identifier (included on system.init) */
protocol?: string
/** Capability names supported by the current process */
capabilities?: string[]
/** Message ID - included on first delta and final message */ /** Message ID - included on first delta and final message */
id?: number id?: number
/** Active task ID when available */
taskId?: string
/** Request ID for correlating streamed output to stdin commands */
requestId?: string
/** Command name for control events */
command?: string
/** Content text (for text-based events) */ /** Content text (for text-based events) */
content?: string content?: string
/** True when this is the final message (stream complete) */ /** True when this is the final message (stream complete) */
done?: boolean done?: boolean
/** Optional subtype for more specific categorization */ /** Optional subtype for more specific categorization */
subtype?: string subtype?: string
/** Optional machine-readable status/error code */
code?: string
/** Current queue depth (for queue events) */
queueDepth?: number
/** Queue item snapshots (for queue events) */
queue?: JsonEventQueueItem[]
/** Tool use information (for tool_use events) */ /** Tool use information (for tool_use events) */
tool_use?: JsonEventToolUse tool_use?: JsonEventToolUse
/** Tool result information (for tool_result events) */ /** Tool result information (for tool_result events) */
@ -107,7 +106,7 @@ export type JsonEvent = RooCliStreamEvent & {
* Final JSON output for "json" mode (single object at end). * Final JSON output for "json" mode (single object at end).
* Contains the result and accumulated messages. * Contains the result and accumulated messages.
*/ */
export type JsonFinalOutput = RooCliFinalOutput & { export interface JsonFinalOutput {
/** Final result type */ /** Final result type */
type: "result" type: "result"
/** Whether the task succeeded */ /** Whether the task succeeded */

View file

@ -7,6 +7,7 @@ export const supportedProviders = [
"gemini", "gemini",
"openrouter", "openrouter",
"vercel-ai-gateway", "vercel-ai-gateway",
"roo",
] as const satisfies ProviderName[] ] as const satisfies ProviderName[]
export type SupportedProvider = (typeof supportedProviders)[number] export type SupportedProvider = (typeof supportedProviders)[number]
@ -19,13 +20,8 @@ export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified"
export type FlagOptions = { export type FlagOptions = {
promptFile?: string promptFile?: string
createWithSessionId?: string
sessionId?: string
continue: boolean
workspace?: string workspace?: string
print: boolean print: boolean
stdinPromptStream: boolean
signalOnlyExit: boolean
extension?: string extension?: string
debug: boolean debug: boolean
requireApproval: boolean requireApproval: boolean
@ -34,15 +30,14 @@ export type FlagOptions = {
provider?: SupportedProvider provider?: SupportedProvider
model?: string model?: string
mode?: string mode?: string
terminalShell?: string
reasoningEffort?: ReasoningEffortFlagOptions reasoningEffort?: ReasoningEffortFlagOptions
consecutiveMistakeLimit?: number
ephemeral: boolean ephemeral: boolean
oneshot: boolean oneshot: boolean
outputFormat?: OutputFormat outputFormat?: OutputFormat
} }
export enum OnboardingProviderChoice { export enum OnboardingProviderChoice {
Roo = "roo",
Byok = "byok", Byok = "byok",
} }
@ -62,8 +57,6 @@ export interface CliSettings {
model?: string model?: string
/** Default reasoning effort level */ /** Default reasoning effort level */
reasoningEffort?: ReasoningEffortFlagOptions reasoningEffort?: ReasoningEffortFlagOptions
/** Default consecutive error/repetition limit before guidance prompts */
consecutiveMistakeLimit?: number
/** Require manual approval for tools/commands/browser/MCP actions */ /** Require manual approval for tools/commands/browser/MCP actions */
requireApproval?: boolean requireApproval?: boolean
/** @deprecated Legacy inverse setting kept for backward compatibility */ /** @deprecated Legacy inverse setting kept for backward compatibility */

View file

@ -60,9 +60,6 @@ const PICKER_HEIGHT = 10
export interface TUIAppProps extends ExtensionHostOptions { export interface TUIAppProps extends ExtensionHostOptions {
initialPrompt?: string initialPrompt?: string
initialTaskId?: string
initialSessionId?: string
continueSession?: boolean
version: string version: string
// Create extension host factory for dependency injection. // Create extension host factory for dependency injection.
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
@ -74,9 +71,6 @@ export interface TUIAppProps extends ExtensionHostOptions {
function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) { function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) {
const { const {
initialPrompt, initialPrompt,
initialTaskId,
initialSessionId,
continueSession,
workspacePath, workspacePath,
extensionPath, extensionPath,
user, user,
@ -176,9 +170,6 @@ function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps)
const { sendToExtension, runTask, cleanup } = useExtensionHost({ const { sendToExtension, runTask, cleanup } = useExtensionHost({
initialPrompt, initialPrompt,
initialTaskId,
initialSessionId,
continueSession,
mode, mode,
reasoningEffort, reasoningEffort,
user, user,

View file

@ -10,13 +10,14 @@ import { getToolRenderer } from "./tools/index.js"
/** /**
* Tool categories for styling * Tool categories for styling
*/ */
type ToolCategory = "file" | "directory" | "search" | "command" | "mode" | "completion" | "other" type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "mode" | "completion" | "other"
function getToolCategory(toolName: string): ToolCategory { function getToolCategory(toolName: string): ToolCategory {
const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"] const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"]
const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"] const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"]
const searchTools = ["searchFiles", "search_files"] const searchTools = ["searchFiles", "search_files"]
const commandTools = ["executeCommand", "execute_command"] const commandTools = ["executeCommand", "execute_command"]
const browserTools = ["browserAction", "browser_action"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"] const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"]
const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"] const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"]
@ -24,6 +25,7 @@ function getToolCategory(toolName: string): ToolCategory {
if (dirTools.includes(toolName)) return "directory" if (dirTools.includes(toolName)) return "directory"
if (searchTools.includes(toolName)) return "search" if (searchTools.includes(toolName)) return "search"
if (commandTools.includes(toolName)) return "command" if (commandTools.includes(toolName)) return "command"
if (browserTools.includes(toolName)) return "browser"
if (modeTools.includes(toolName)) return "mode" if (modeTools.includes(toolName)) return "mode"
if (completionTools.includes(toolName)) return "completion" if (completionTools.includes(toolName)) return "completion"
return "other" return "other"
@ -37,6 +39,7 @@ const CATEGORY_COLORS: Record<ToolCategory, string> = {
directory: theme.toolHeader, directory: theme.toolHeader,
search: theme.warningColor, search: theme.warningColor,
command: theme.successColor, command: theme.successColor,
browser: theme.focusColor,
mode: theme.userHeader, mode: theme.userHeader,
completion: theme.successColor, completion: theme.successColor,
other: theme.toolHeader, other: theme.toolHeader,

View file

@ -0,0 +1,28 @@
import { Box, Text } from "ink"
import { Select } from "@inkjs/ui"
import { OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js"
export interface OnboardingScreenProps {
onSelect: (choice: OnboardingProviderChoice) => void
}
export function OnboardingScreen({ onSelect }: OnboardingScreenProps) {
return (
<Box flexDirection="column" gap={1}>
<Text bold color="cyan">
{ASCII_ROO}
</Text>
<Text dimColor>Welcome! How would you like to connect to an LLM provider?</Text>
<Select
options={[
{ label: "Connect to Roo Code Cloud", value: OnboardingProviderChoice.Roo },
{ label: "Bring your own API key", value: OnboardingProviderChoice.Byok },
]}
onChange={(value: string) => {
onSelect(value as OnboardingProviderChoice)
}}
/>
</Box>
)
}

View file

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

View file

@ -0,0 +1,87 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { getToolDisplayName, getToolIconName } from "./utils.js"
const ACTION_LABELS: Record<string, string> = {
launch: "Launch Browser",
click: "Click",
hover: "Hover",
type: "Type Text",
press: "Press Key",
scroll_down: "Scroll Down",
scroll_up: "Scroll Up",
resize: "Resize Window",
close: "Close Browser",
screenshot: "Take Screenshot",
}
export function BrowserTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const action = toolData.action || ""
const url = toolData.url || ""
const coordinate = toolData.coordinate || ""
const content = toolData.content || "" // May contain text for type action.
const actionLabel = ACTION_LABELS[action] || action
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{action && (
<Text color={theme.focusColor} bold>
{" "}
{actionLabel}
</Text>
)}
</Box>
{/* Action details */}
<Box flexDirection="column" marginLeft={2}>
{/* URL for launch action */}
{url && (
<Box>
<Text color={theme.dimText}>url: </Text>
<Text color={theme.text} underline>
{url}
</Text>
</Box>
)}
{/* Coordinates for click/hover actions */}
{coordinate && (
<Box>
<Text color={theme.dimText}>at: </Text>
<Text color={theme.warningColor}>{coordinate}</Text>
</Box>
)}
{/* Text content for type action */}
{content && action === "type" && (
<Box>
<Text color={theme.dimText}>text: </Text>
<Text color={theme.text}>"{content}"</Text>
</Box>
)}
{/* Key for press action */}
{content && action === "press" && (
<Box>
<Text color={theme.dimText}>key: </Text>
<Text color={theme.successColor}>{content}</Text>
</Box>
)}
</Box>
</Box>
)
}

View file

@ -15,6 +15,7 @@ import { FileReadTool } from "./FileReadTool.js"
import { FileWriteTool } from "./FileWriteTool.js" import { FileWriteTool } from "./FileWriteTool.js"
import { SearchTool } from "./SearchTool.js" import { SearchTool } from "./SearchTool.js"
import { CommandTool } from "./CommandTool.js" import { CommandTool } from "./CommandTool.js"
import { BrowserTool } from "./BrowserTool.js"
import { ModeTool } from "./ModeTool.js" import { ModeTool } from "./ModeTool.js"
import { CompletionTool } from "./CompletionTool.js" import { CompletionTool } from "./CompletionTool.js"
import { GenericTool } from "./GenericTool.js" import { GenericTool } from "./GenericTool.js"
@ -31,6 +32,7 @@ export { FileReadTool } from "./FileReadTool.js"
export { FileWriteTool } from "./FileWriteTool.js" export { FileWriteTool } from "./FileWriteTool.js"
export { SearchTool } from "./SearchTool.js" export { SearchTool } from "./SearchTool.js"
export { CommandTool } from "./CommandTool.js" export { CommandTool } from "./CommandTool.js"
export { BrowserTool } from "./BrowserTool.js"
export { ModeTool } from "./ModeTool.js" export { ModeTool } from "./ModeTool.js"
export { CompletionTool } from "./CompletionTool.js" export { CompletionTool } from "./CompletionTool.js"
export { GenericTool } from "./GenericTool.js" export { GenericTool } from "./GenericTool.js"
@ -43,6 +45,7 @@ const CATEGORY_RENDERERS: Record<string, React.FC<ToolRendererProps>> = {
"file-write": FileWriteTool, "file-write": FileWriteTool,
search: SearchTool, search: SearchTool,
command: CommandTool, command: CommandTool,
browser: BrowserTool,
mode: ModeTool, mode: ModeTool,
completion: CompletionTool, completion: CompletionTool,
other: GenericTool, other: GenericTool,

View file

@ -5,7 +5,15 @@ export interface ToolRendererProps {
rawContent?: string rawContent?: string
} }
export type ToolCategory = "file-read" | "file-write" | "search" | "command" | "mode" | "completion" | "other" export type ToolCategory =
| "file-read"
| "file-write"
| "search"
| "command"
| "browser"
| "mode"
| "completion"
| "other"
export function getToolCategory(toolName: string): ToolCategory { export function getToolCategory(toolName: string): ToolCategory {
const fileReadTools = ["readFile", "read_file", "skill", "listFilesTopLevel", "listFilesRecursive", "list_files"] const fileReadTools = ["readFile", "read_file", "skill", "listFilesTopLevel", "listFilesRecursive", "list_files"]
@ -21,6 +29,7 @@ export function getToolCategory(toolName: string): ToolCategory {
const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"] const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"]
const commandTools = ["execute_command", "executeCommand"] const commandTools = ["execute_command", "executeCommand"]
const browserTools = ["browser_action", "browserAction"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"] const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"]
const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"] const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"]
@ -28,6 +37,7 @@ export function getToolCategory(toolName: string): ToolCategory {
if (fileWriteTools.includes(toolName)) return "file-write" if (fileWriteTools.includes(toolName)) return "file-write"
if (searchTools.includes(toolName)) return "search" if (searchTools.includes(toolName)) return "search"
if (commandTools.includes(toolName)) return "command" if (commandTools.includes(toolName)) return "command"
if (browserTools.includes(toolName)) return "browser"
if (modeTools.includes(toolName)) return "mode" if (modeTools.includes(toolName)) return "mode"
if (completionTools.includes(toolName)) return "completion" if (completionTools.includes(toolName)) return "completion"
return "other" return "other"

View file

@ -73,6 +73,10 @@ export function getToolDisplayName(toolName: string): string {
execute_command: "Execute Command", execute_command: "Execute Command",
executeCommand: "Execute Command", executeCommand: "Execute Command",
// Browser operations
browser_action: "Browser Action",
browserAction: "Browser Action",
// Mode operations // Mode operations
switchMode: "Switch Mode", switchMode: "Switch Mode",
switch_mode: "Switch Mode", switch_mode: "Switch Mode",
@ -125,6 +129,10 @@ export function getToolIconName(toolName: string): IconName {
execute_command: "terminal", execute_command: "terminal",
executeCommand: "terminal", executeCommand: "terminal",
// Browser operations
browser_action: "browser",
browserAction: "browser",
// Mode operations // Mode operations
switchMode: "switch", switchMode: "switch",
switch_mode: "switch", switch_mode: "switch",

Some files were not shown because too many files have changed in this diff Show more