diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index fe1ec8409b..96234eb25d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,6 +1,5 @@ name: Bug report description: Report an issue that should be fixed -labels: ["bug"] body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c47570..42f1d3c51a 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,6 +1,5 @@ name: 🚀 Feature Request description: Suggest an idea, feature, or enhancement -labels: [discussion] title: "[FEATURE]:" body: diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2310bfcc86..8930ba693c 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,6 +1,5 @@ name: Question description: Ask a question -labels: ["question"] body: - type: textarea id: question diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index 3b8519d3bb..e5f8f000e0 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -11,6 +11,5 @@ MrMushrooooom nexxeln R44VC0RP rekram1-node -RhysSullivan thdxr simonklee diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 8618701ebf..0000000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,40 +0,0 @@ -# Vouched contributors for this project. -# -# See https://github.com/mitchellh/vouch for details. -# -# Syntax: -# - One handle per line (without @), sorted alphabetically. -# - Optional platform prefix: platform:username (e.g., github:user). -# - Denounce with minus prefix: -username or -platform:username. -# - Optional details after a space following the handle. -adamdotdevin --agusbasari29 AI PR slop -ariane-emory --atharvau AI review spamming literally every PR --borealbytes --carycooper777 --danieljoshuanazareth --danieljoshuanazareth --davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person -dmtrkovalenko -edemaine -fahreddinozcan --florianleibert -fwang -iamdavidhill -jayair -kitlangton -kommander --opencode2026 --opencodeengineer bot that spams issues -r44vc0rp -rekram1-node --ricardo-m-l --robinmordasiewicz -rubdos -shantur -simonklee --spider-yamet clawdbot/llm psychosis, spam pinging the team --terisuke -thdxr --toastythebot diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/daily-issues-recap.yml deleted file mode 100644 index 31cf08233b..0000000000 --- a/.github/workflows/daily-issues-recap.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: daily-issues-recap - -on: - schedule: - # Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving) - - cron: "0 23 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - daily-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily issues recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - # Get today's date range - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather today's issues - Search for all OPEN issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 - - IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) issues only. - - STEP 2: Analyze and categorize - For each issue created today, categorize it: - - **Severity Assessment:** - - CRITICAL: Crashes, data loss, security issues, blocks major functionality - - HIGH: Significant bugs affecting many users, important features broken - - MEDIUM: Bugs with workarounds, minor features broken - - LOW: Minor issues, cosmetic, nice-to-haves - - **Activity Assessment:** - - Note issues with high comment counts or engagement - - Note issues from repeat reporters (check if author has filed before) - - STEP 3: Cross-reference with existing issues - For issues that seem like feature requests or recurring bugs: - - Search for similar older issues to identify patterns - - Note if this is a frequently requested feature - - Identify any issues that are duplicates of long-standing requests - - STEP 4: Generate the recap - Create a structured recap with these sections: - - ===DISCORD_START=== - **Daily Issues Recap - ${TODAY}** - - **Summary Stats** - - Total issues opened today: [count] - - By category: [bugs/features/questions] - - **Critical/High Priority Issues** - [List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers] - - **Most Active/Discussed** - [Issues with significant engagement or from active community members] - - **Trending Topics** - [Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature'] - - **Duplicates & Related** - [Issues that relate to existing open issues] - ===DISCORD_END=== - - STEP 5: Format for Discord - Format the recap as a Discord-compatible message: - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report - - Use hyperlinked issue numbers with suppressed embeds: [#1234]() - - Group related issues on single lines where possible - - Add emoji sparingly for critical items only - - HARD LIMIT: Keep under 1800 characters total - - Skip sections that have nothing notable (e.g., if no critical issues, omit that section) - - Prioritize signal over completeness - only surface what matters - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt - - echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily recap to Discord" diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/daily-pr-recap.yml deleted file mode 100644 index 2f0f023cfd..0000000000 --- a/.github/workflows/daily-pr-recap.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: daily-pr-recap - -on: - schedule: - # Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving) - - cron: "0 22 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - pr-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily PR recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh pr*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - - # Open PRs created today - gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - # Open PRs with activity today (updated today) - gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) contributions only. - - - - STEP 2: For high-activity PRs, check comment counts - For promising PRs, run: - gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length' - - IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts: - - copilot-pull-request-reviewer - - github-actions - - STEP 3: Identify what matters (ONLY from today's PRs) - - **Bug Fixes From Today:** - - PRs with 'fix' or 'bug' in title created/updated today - - Small bug fixes (< 100 lines changed) that are easy to review - - Bug fixes from community contributors - - **High Activity Today:** - - PRs with significant human comments today (excluding bots listed above) - - PRs with back-and-forth discussion today - - **Quick Wins:** - - Small PRs (< 50 lines) that are approved or nearly approved - - PRs that just need a final review - - STEP 4: Generate the recap - Create a structured recap: - - ===DISCORD_START=== - **Daily PR Recap - ${TODAY}** - - **New PRs Today** - [PRs opened today - group by type: bug fixes, features, etc.] - - **Active PRs Today** - [PRs with activity/updates today - significant discussion] - - **Quick Wins** - [Small PRs ready to merge] - ===DISCORD_END=== - - STEP 5: Format for Discord - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - surface what we might miss - - Use hyperlinked PR numbers with suppressed embeds: [#1234]() - - Include PR author: [#1234]() (@author) - - For bug fixes, add brief description of what it fixes - - Show line count for quick wins: \"(+15/-3 lines)\" - - HARD LIMIT: Keep under 1800 characters total - - Skip empty sections - - Focus on PRs that need human eyes - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt - - echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/pr_recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/pr_recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily PR recap to Discord" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 96f437a73f..e346d0cd5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,3 +36,9 @@ jobs: PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: web@${{ github.sha }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_RELEASE: web@${{ github.sha }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd9b60f8bd..4614226a8a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,182 +209,6 @@ jobs: packages/opencode/dist/opencode-windows-x64 packages/opencode/dist/opencode-windows-x64-baseline - build-tauri: - needs: - - build-cli - - version - continue-on-error: false - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: x86_64-apple-darwin - - host: macos-latest - target: aarch64-apple-darwin - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: windows-2025 - target: aarch64-pc-windows-msvc - - host: blacksmith-4vcpu-windows-2025 - target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 - target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.settings.host }} - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: apple-actions/import-codesign-certs@v2 - if: ${{ runner.os == 'macOS' }} - with: - keychain: build - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Verify Certificate - if: ${{ runner.os == 'macOS' }} - run: | - CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - echo "Certificate imported." - - - name: Setup Apple API Key - if: ${{ runner.os == 'macOS' }} - run: | - echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8 - - - uses: ./.github/actions/setup-bun - - - name: Azure login - if: runner.os == 'Windows' - uses: azure/login@v2 - with: - client-id: ${{ env.AZURE_CLIENT_ID }} - tenant-id: ${{ env.AZURE_TENANT_ID }} - subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Cache apt packages - if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 - with: - path: ~/apt-cache - key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.settings.target }}-apt- - - - name: install dependencies (ubuntu only) - if: contains(matrix.settings.host, 'ubuntu') - run: | - mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache - sudo apt-get update - sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - sudo chmod -R a+rw ~/apt-cache - - - name: install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.settings.target }} - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: packages/desktop/src-tauri - shared-key: ${{ matrix.settings.target }} - - - name: Prepare - run: | - cd packages/desktop - bun ./scripts/prepare.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} - RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - - - name: Resolve tauri portable SHA - if: contains(matrix.settings.host, 'ubuntu') - run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV" - - # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - name: Install tauri-cli from portable appimage branch - uses: taiki-e/cache-cargo-install-action@v3 - if: contains(matrix.settings.host, 'ubuntu') - with: - tool: tauri-cli - git: https://github.com/tauri-apps/tauri - # branch: feat/truly-portable-appimage - rev: ${{ env.TAURI_PORTABLE_SHA }} - - - name: Show tauri-cli version - if: contains(matrix.settings.host, 'ubuntu') - run: cargo tauri --version - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Build and upload artifacts - uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a - timeout-minutes: 60 - with: - projectPath: packages/desktop - uploadWorkflowArtifacts: true - tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }} - args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose - updaterJsonPreferNsis: true - releaseId: ${{ needs.version.outputs.release }} - tagName: ${{ needs.version.outputs.tag }} - releaseDraft: true - releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] - repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }} - releaseCommitish: ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 - - - name: Verify signed Windows desktop artifacts - if: runner.os == 'Windows' - shell: pwsh - run: | - $files = @( - "${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe" - ) - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName - - foreach ($file in $files) { - $sig = Get-AuthenticodeSignature $file - if ($sig.Status -ne "Valid") { - throw "Invalid signature for ${file}: $($sig.Status)" - } - } - build-electron: needs: - build-cli @@ -494,6 +318,13 @@ jobs: working-directory: packages/desktop-electron env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} + SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} + VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} - name: Package and publish if: needs.version.outputs.release @@ -517,6 +348,30 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + - name: Create and upload macOS .app.tar.gz + if: runner.os == 'macOS' && needs.version.outputs.release + working-directory: packages/desktop-electron/dist + env: + GH_TOKEN: ${{ steps.committer.outputs.token }} + run: | + if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then + APP_DIR="mac" + OUT_NAME="opencode-desktop-mac-x64.app.tar.gz" + elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then + APP_DIR="mac-arm64" + OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz" + else + echo "Unknown macOS target: ${{ matrix.settings.target }}" + exit 1 + fi + APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app bundle found in $APP_DIR" + exit 1 + fi + tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" + gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}" + - name: Verify signed Windows Electron artifacts if: runner.os == 'Windows' shell: pwsh @@ -535,7 +390,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: opencode-electron-${{ matrix.settings.target }} + name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop-electron/dist/* - uses: actions/upload-artifact@v4 @@ -549,7 +404,6 @@ jobs: - version - build-cli - sign-cli-windows - - build-tauri - build-electron if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -576,13 +430,6 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - uses: actions/download-artifact@v4 with: name: opencode-cli @@ -604,6 +451,13 @@ jobs: pattern: latest-yml-* path: /tmp/latest-yml + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Cache apt packages (AUR) uses: actions/cache@v4 with: @@ -632,3 +486,5 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} NPM_CONFIG_PROVENANCE: false LATEST_YML_DIR: /tmp/latest-yml + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml deleted file mode 100644 index 4c2aa960b2..0000000000 --- a/.github/workflows/vouch-check-issue.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: vouch-check-issue - -on: - issues: - types: [opened] - -permissions: - contents: read - issues: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if issue author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.issue.user.login; - const issueNumber = context.payload.issue.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the issue - const body = 'This issue has been automatically closed.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'closed', - state_reason: 'not_planned', - }); - - core.info(`Closed issue #${issueNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing issue.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to issue #${issueNumber} from ${author}`); diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml deleted file mode 100644 index 51816dfb75..0000000000 --- a/.github/workflows/vouch-check-pr.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: vouch-check-pr - -on: - pull_request_target: - types: [opened] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if PR author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.pull_request.user.login; - const prNumber = context.payload.pull_request.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: 'This pull request has been automatically closed.', - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - state: 'closed', - }); - - core.info(`Closed PR #${prNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing PR.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to PR #${prNumber} from ${author}`); diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml deleted file mode 100644 index 79687639df..0000000000 --- a/.github/workflows/vouch-manage-by-issue.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: vouch-manage-by-issue - -on: - issue_comment: - types: [created] - -concurrency: - group: vouch-manage - cancel-in-progress: false - -permissions: - contents: write - issues: write - pull-requests: read - -jobs: - manage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - uses: mitchellh/vouch/action/manage-by-issue@main - with: - issue-id: ${{ github.event.issue.number }} - comment-id: ${{ github.event.comment.id }} - roles: admin,maintain,write - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a77b92737b..03df339cb8 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/minimax-m2.5 +model: opencode/gpt-5.4-nano color: "#44BA81" tools: "*": false @@ -14,127 +14,30 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -## Labels +Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. -### windows +Do not add labels to issues. Only assign an owner. -Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. +When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. -- Use if they mention WSL too +## Teams -#### perf +### TUI -Performance-related issues: +Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -- Slow performance -- High RAM usage -- High CPU usage +### Desktop / Web -**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. +Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -#### desktop +### Core -Desktop app issues: +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. -- `opencode web` command -- The desktop app itself +### Inference -**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues. +OpenCode Zen, OpenCode Go, and billing issues. -#### nix +### Windows -**Only** add if the issue explicitly mentions nix. - -If the issue does not mention nix, do not add nix. - -If the issue mentions nix, assign to `rekram1-node`. - -#### zen - -**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". - -If the issue doesn't have "zen" or "opencode black" in it then don't add zen label - -#### core - -Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. - -Examples: - -- LSP server behavior -- Harness behavior (agent + tools) -- Feature requests for server behavior -- Agent context construction -- API endpoints -- Provider integration issues -- New, broken, or poor-quality models - -#### acp - -If the issue mentions acp support, assign acp label. - -#### docs - -Add if the issue requests better documentation or docs updates. - -#### opentui - -TUI issues potentially caused by our underlying TUI library: - -- Keybindings not working -- Scroll speed issues (too fast/slow/laggy) -- Screen flickering -- Crashes with opentui in the log - -**Do not** add for general TUI bugs. - -When assigning to people here are the following rules: - -Desktop / Web: -Use for desktop-labeled issues only. - -- adamdotdevin -- iamdavidhill -- Brendonovich -- nexxeln - -Zen: -ONLY assign if the issue will have the "zen" label. - -- fwang -- MrMushrooooom - -TUI (`packages/opencode/src/cli/cmd/tui/...`): - -- thdxr for TUI UX/UI product decisions and interaction flow -- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks -- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues - -Core (`packages/opencode/...`, excluding TUI subtree): - -- thdxr for sqlite/snapshot/memory bugs and larger architectural core features -- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable) -- rekram1-node for harness issues, provider issues, and other bug-squashing - -For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable. - -Docs: - -- R44VC0RP - -Windows: - -- Hona (assign any issue that mentions Windows or is likely Windows-specific) - -Determinism rules: - -- If title + body does not contain "zen", do not add the "zen" label -- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" -- If title + body mentions nix/nixos, assign to `rekram1-node` -- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner - -In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. - -ACP: - -- rekram1-node (assign any acp issues to rekram1-node) +Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 4cd30a704a..b28d963d00 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution. Rules: -- Write the final file with sections in this order: +- Write the final file with release sections in this order: `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` - Only include sections that have at least one notable entry +- Within each release section, keep bug fixes grouped under `### Bugfixes` +- Keep other notable entries under `### Improvements` when a section has bug fixes too +- Omit empty subsections - Keep one bullet per commit you keep - Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing - Start each bullet with a capital letter diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 56886808a4..35db44641e 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,16 +1,14 @@ /// import { tool } from "@opencode-ai/plugin" + const TEAM = { - desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], - zen: ["fwang", "MrMushrooooom"], - tui: ["kommander", "rekram1-node", "simonklee"], - core: ["kitlangton", "rekram1-node", "jlongster"], - docs: ["R44VC0RP"], + tui: ["kommander", "simonklee"], + desktop_web: ["Hona", "Brendonovich"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + inference: ["fwang", "MrMushrooooom"], windows: ["Hona"], } as const -const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] - function pick(items: readonly T[]) { return items[Math.floor(Math.random() * items.length)]! } @@ -38,79 +36,25 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: `Use this tool to assign and/or label a GitHub issue. + description: `Use this tool to assign a GitHub issue. -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - assignee: tool.schema - .enum(ASSIGNEES as [string, ...string[]]) - .describe("The username of the assignee") - .default("rekram1-node"), - labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) - .describe("The labels(s) to add to the issue") - .default([]), + team: tool.schema + .enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) + .describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() const owner = "anomalyco" const repo = "opencode" - - const results: string[] = [] - let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] - const web = labels.includes("web") - const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() - const zen = /\bzen\b/.test(text) || text.includes("opencode black") - const nix = /\bnix(os)?\b/.test(text) - - if (labels.includes("nix") && !nix) { - labels = labels.filter((x) => x !== "nix") - results.push("Dropped label: nix (issue does not mention nix)") - } - - const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee - - if (labels.includes("zen") && !zen) { - throw new Error("Only add the zen label when issue title/body contains 'zen'") - } - - if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { - throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") - } - - if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") - } - - if (assignee === "Hona" && !labels.includes("windows")) { - throw new Error("Only windows issues should be assigned to Hona") - } - - if (assignee === "R44VC0RP" && !labels.includes("docs")) { - throw new Error("Only docs issues should be assigned to R44VC0RP") - } - - if (assignee === "kommander" && !labels.includes("opentui")) { - throw new Error("Only opentui issues should be assigned to kommander") - } + const assignee = pick(TEAM[args.team]) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${assignee} to issue #${issue}`) - if (labels.length > 0) { - await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { - method: "POST", - body: JSON.stringify({ labels }), - }) - results.push(`Added labels: ${labels.join(", ")}`) - } - - return results.join("\n") + return `Assigned @${assignee} from ${args.team} to issue #${issue}` }, }) diff --git a/README.md b/README.md index 79ccf8b349..3ebfb1627c 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ - 100% open source - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. -- Out-of-the-box LSP support +- Built-in opt-in LSP support - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients. diff --git a/bun.lock b/bun.lock index 093bb880a4..07415dd79f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,12 +29,13 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", @@ -69,6 +70,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -83,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -117,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -144,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -168,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -192,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.30", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -226,10 +228,11 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", @@ -250,6 +253,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", @@ -259,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -275,6 +279,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", + "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", @@ -303,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -332,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -348,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.30", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -490,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -505,8 +511,8 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.0", - "@opentui/solid": ">=0.2.0", + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2", }, "optionalPeers": [ "@opentui/core", @@ -525,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "cross-spawn": "catalog:", }, @@ -540,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -575,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -624,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -684,10 +690,12 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.0", - "@opentui/solid": "0.2.0", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", @@ -707,7 +715,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -1610,21 +1618,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="], + "@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], - "@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="], + "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -1956,6 +1964,44 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="], + + "@sentry-internal/feedback": ["@sentry-internal/feedback@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-zPjz7AbcxEyx8AHj8xvp28fYtPTPWU1XcNtymhAHJLS9CXOblqSC7W02Jxz6eo3eR1/pLyOo6kJBUjvLe9EoFA=="], + + "@sentry-internal/replay": ["@sentry-internal/replay@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-nLMkJgvHq+uCCrQKV2KgSdVHxTsmDk0r2hsAoTcKCbzUpXyW5UhCziMRS6ULjBlzt5sbxoIIplE25ZpmIEeNgg=="], + + "@sentry-internal/replay-canvas": ["@sentry-internal/replay-canvas@10.36.0", "", { "dependencies": { "@sentry-internal/replay": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-DLGIwmT2LX+O6TyYPtOQL5GiTm2rN0taJPDJ/Lzg2KEJZrdd5sKkzTckhh2x+vr4JQyeaLmnb8M40Ch1hvG/vQ=="], + + "@sentry/babel-plugin-component-annotate": ["@sentry/babel-plugin-component-annotate@4.6.0", "", {}, "sha512-3soTX50JPQQ51FSbb4qvNBf4z/yP7jTdn43vMTp9E4IxvJ9HKJR7OEuKkCMszrZmWsVABXl02msqO7QisePdiQ=="], + + "@sentry/browser": ["@sentry/browser@10.36.0", "", { "dependencies": { "@sentry-internal/browser-utils": "10.36.0", "@sentry-internal/feedback": "10.36.0", "@sentry-internal/replay": "10.36.0", "@sentry-internal/replay-canvas": "10.36.0", "@sentry/core": "10.36.0" } }, "sha512-yHhXbgdGY1s+m8CdILC9U/II7gb6+s99S2Eh8VneEn/JG9wHc+UOzrQCeFN0phFP51QbLkjkiQbbanjT1HP8UQ=="], + + "@sentry/bundler-plugin-core": ["@sentry/bundler-plugin-core@4.6.0", "", { "dependencies": { "@babel/core": "^7.18.5", "@sentry/babel-plugin-component-annotate": "4.6.0", "@sentry/cli": "^2.57.0", "dotenv": "^16.3.1", "find-up": "^5.0.0", "glob": "^9.3.2", "magic-string": "0.30.8", "unplugin": "1.0.1" } }, "sha512-Fub2XQqrS258jjS8qAxLLU1k1h5UCNJ76i8m4qZJJdogWWaF8t00KnnTyp9TEDJzrVD64tRXS8+HHENxmeUo3g=="], + + "@sentry/cli": ["@sentry/cli@2.58.5", "", { "dependencies": { "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.7", "progress": "^2.0.3", "proxy-from-env": "^1.1.0", "which": "^2.0.2" }, "optionalDependencies": { "@sentry/cli-darwin": "2.58.5", "@sentry/cli-linux-arm": "2.58.5", "@sentry/cli-linux-arm64": "2.58.5", "@sentry/cli-linux-i686": "2.58.5", "@sentry/cli-linux-x64": "2.58.5", "@sentry/cli-win32-arm64": "2.58.5", "@sentry/cli-win32-i686": "2.58.5", "@sentry/cli-win32-x64": "2.58.5" }, "bin": { "sentry-cli": "bin/sentry-cli" } }, "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg=="], + + "@sentry/cli-darwin": ["@sentry/cli-darwin@2.58.5", "", { "os": "darwin" }, "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ=="], + + "@sentry/cli-linux-arm": ["@sentry/cli-linux-arm@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm" }, "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw=="], + + "@sentry/cli-linux-arm64": ["@sentry/cli-linux-arm64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "arm64" }, "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ=="], + + "@sentry/cli-linux-i686": ["@sentry/cli-linux-i686@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "ia32" }, "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw=="], + + "@sentry/cli-linux-x64": ["@sentry/cli-linux-x64@2.58.5", "", { "os": [ "linux", "android", "freebsd", ], "cpu": "x64" }, "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g=="], + + "@sentry/cli-win32-arm64": ["@sentry/cli-win32-arm64@2.58.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA=="], + + "@sentry/cli-win32-i686": ["@sentry/cli-win32-i686@2.58.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g=="], + + "@sentry/cli-win32-x64": ["@sentry/cli-win32-x64@2.58.5", "", { "os": "win32", "cpu": "x64" }, "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg=="], + + "@sentry/core": ["@sentry/core@10.36.0", "", {}, "sha512-EYJjZvofI+D93eUsPLDIUV0zQocYqiBRyXS6CCV6dHz64P/Hob5NJQOwPa8/v6nD+UvJXvwsFfvXOHhYZhZJOQ=="], + + "@sentry/solid": ["@sentry/solid@10.36.0", "", { "dependencies": { "@sentry/browser": "10.36.0", "@sentry/core": "10.36.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "optionalPeers": ["@solidjs/router", "@tanstack/solid-router"] }, "sha512-AaDqz3JGBrQCm2YVqODVyJHwg7LRTNSJig9mjfProFyvkC7eUXQ/HBJrrhAD1Dct9ufmDH3G+f3/Ut9LgpItSg=="], + + "@sentry/vite-plugin": ["@sentry/vite-plugin@4.6.0", "", { "dependencies": { "@sentry/bundler-plugin-core": "4.6.0", "unplugin": "1.0.1" } }, "sha512-fMR2d+EHwbzBa0S1fp45SNUTProxmyFBp+DeBWWQOSP9IU6AH6ea2rqrpMAnp/skkcdW4z4LSRrOEpMZ5rWXLw=="], + "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], @@ -2722,21 +2768,21 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="], + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], - "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -3032,7 +3078,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -3240,7 +3286,7 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="], @@ -3734,7 +3780,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], @@ -4138,7 +4184,7 @@ "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], - "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -4158,7 +4204,7 @@ "pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="], - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], @@ -4188,7 +4234,7 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -4948,7 +4994,7 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], "unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="], @@ -5056,7 +5102,9 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webpack-sources": ["webpack-sources@3.4.0", "", {}, "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.5.0", "", {}, "sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], @@ -5592,6 +5640,8 @@ "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -5608,6 +5658,16 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + + "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + + "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "@sentry/cli/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "@sentry/cli/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -5662,6 +5722,8 @@ "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.48", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw=="], + "@storybook/csf-plugin/unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], @@ -5846,8 +5908,6 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "find-up/path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -5958,7 +6018,7 @@ "ora/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], @@ -5970,6 +6030,8 @@ "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -6062,12 +6124,16 @@ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], - "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -6566,6 +6632,16 @@ "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "@sentry/cli/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6588,6 +6664,8 @@ "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -6722,7 +6800,7 @@ "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], - "opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -6734,8 +6812,12 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="], "readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -6764,6 +6846,8 @@ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -6988,6 +7072,12 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], @@ -7070,20 +7160,12 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], - "opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="], "pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], @@ -7096,6 +7178,8 @@ "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7150,6 +7234,8 @@ "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], @@ -7176,6 +7262,8 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pkg-up/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/nix/hashes.json b/nix/hashes.json index d9169d95c3..84c3b13043 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-QthnHaV7hTxvxEzQpxI7HWjb2n1ZMTYviA7DDdAzJwk=", - "aarch64-linux": "sha256-l6/2wVmNBBtCI0ovhfhyq3ZSebj6qZFWXptYqUy2Rh8=", - "aarch64-darwin": "sha256-upNQqcwa/b/XPyQFTMOkp6S5QvILg5Y3LnDCmEe9iqA=", - "x86_64-darwin": "sha256-/M91h4YUTsBveDhwtl5mTkQaRE+92WZmzs2p4D+RuBk=" + "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=", + "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=", + "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=", + "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI=" } } diff --git a/nix/node_modules.nix b/nix/node_modules.nix index ba97405df9..e10e85d2fe 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -55,7 +55,6 @@ stdenvNoCC.mkDerivation { --filter './packages/opencode' \ --filter './packages/desktop' \ --filter './packages/app' \ - --filter './packages/shared' \ --frozen-lockfile \ --ignore-scripts \ --no-progress diff --git a/package.json b/package.json index 975f177743..de3dd31f40 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.0", - "@opentui/solid": "0.2.0", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -53,7 +53,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", @@ -77,6 +77,8 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", + "@sentry/solid": "10.36.0", + "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" diff --git a/packages/app/package.json b/packages/app/package.json index 1a5a1a007f..cde4986d18 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.30", + "version": "1.14.35", "description": "", "type": "module", "exports": { @@ -27,6 +27,7 @@ "devDependencies": { "@happy-dom/global-registrator": "20.0.11", "@playwright/test": "catalog:", + "@sentry/vite-plugin": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/bun": "1.0.9", "@types/bun": "catalog:", @@ -40,6 +41,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@sentry/solid": "catalog:", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@opencode-ai/core": "workspace:*", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index bf8138fcde..3189d80257 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,4 +1,5 @@ import "@/index.css" +import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" @@ -148,12 +149,19 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { > - }> - - - {props.children} - - + { + Sentry.captureException(error) + return + }} + > + + + + {props.children} + + + diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ff5ff9dada..6dae9de955 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" +import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" @@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } -const errorName = (err: unknown) => { - if (!err || typeof err !== "object") return - if (!("name" in err)) return - const errorName = err.name - return typeof errorName === "string" ? errorName : undefined -} - const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -478,14 +472,34 @@ export const Terminal = (props: TerminalProps) => { const gone = () => client.pty - .get({ ptyID: id }) - .then(() => false) + .get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) .catch((err) => { - if (errorName(err) === "NotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) + const connectToken = async () => { + const result = await client.pty + .connectToken( + { ptyID: id, directory }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if (result.response.status === 404 || result.response.status === 405) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) + } + const retry = (err: unknown) => { if (disposed) return if (reconn !== undefined) return @@ -505,22 +519,30 @@ export const Terminal = (props: TerminalProps) => { }, ms) } - const open = () => { + const open = async () => { if (disposed) return drop?.() - const next = new URL(url + `/pty/${id}/connect`) - next.searchParams.set("directory", directory) - next.searchParams.set("cursor", String(seek)) - next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!sameOrigin && password) { - next.searchParams.set("auth_token", btoa(`${username}:${password}`)) - // For same-origin requests, let the browser reuse the page's existing auth. - next.username = username - next.password = password - } + const ticket = await connectToken().catch((err) => { + fail(err) + return undefined + }) + if (once.value) return + if (disposed) return - const socket = new WebSocket(next) + const socket = new WebSocket( + terminalWebSocketURL({ + url, + id, + directory, + cursor: seek, + ticket, + sameOrigin, + username, + password, + authToken: server.current?.type === "http" ? server.current.authToken : false, + }), + ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 409fcbeff6..2917b7adb8 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -35,6 +35,9 @@ type TauriApi = { const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() +const titlebarHeight = 40 +const minTitlebarZoom = 0.25 +const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. export function Titlebar() { const layout = useLayout() @@ -51,7 +54,14 @@ export function Titlebar() { const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") const zoom = () => platform.webviewZoom?.() ?? 1 - const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) + const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) + const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) + const minHeight = () => { + if (mac()) return `${titlebarHeight / zoom()}px` + if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px` + return undefined + } + const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const [history, setHistory] = createStore({ stack: [] as string[], @@ -165,156 +175,161 @@ export function Titlebar() { return (
- -
-
- -
- - -
- -
-
-
- - - - -
-
-
+
+
+
-
-
- - {!tauriApi() &&
} -
- +
+
+ + {!tauriApi() &&
} +
+ +
) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ba9f6d52ab..6190deb1ee 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -204,6 +204,9 @@ function createGlobalSync() { }, translate: language.t, getSdk: sdkFor, + global: { + provider: globalStore.provider, + }, }) async function loadSessions(directory: string) { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 451531835d..e85516bf14 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -260,9 +260,6 @@ export async function bootstrapDirectory(input: { const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined if (seededProject) input.setStore("project", seededProject) if (seededPath) input.setStore("path", seededPath) - if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { - input.setStore("provider", input.global.provider) - } if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { input.setStore("config", reconcile(input.global.config, { merge: false })) } diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 24b4a46500..30dda86919 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -23,6 +23,7 @@ describe("createChildStoreManager", () => { onDispose() {}, translate: (key) => key, getSdk: () => null!, + global: { provider: null! }, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index a7209d3dbd..0138310cdc 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -1,7 +1,7 @@ import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { Persist, persisted } from "@/utils/persist" -import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client" +import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import { DIR_IDLE_TTL_MS, MAX_DIR_STORES, @@ -27,6 +27,9 @@ export function createChildStoreManager(input: { onDispose: (directory: string) => void translate: (key: string, vars?: Record) => string getSdk: (directory: string) => OpencodeClient + global: { + provider: ProviderListResponse + } }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -189,7 +192,13 @@ export function createChildStoreManager(input: { get provider_ready() { return !providerQuery.isLoading }, - provider: { all: [], connected: [], default: {} }, + get provider() { + const EMPTY = { all: [], connected: [], default: {} } + if (providerQuery.isLoading) return EMPTY + if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0) + return input.global.provider + return providerQuery.data ?? EMPTY + }, config: {}, get path() { if (pathQuery.isLoading || !pathQuery.data) diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts new file mode 100644 index 0000000000..1fa35247c8 --- /dev/null +++ b/packages/app/src/context/server.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { resolveServerList, ServerConnection } from "./server" + +describe("resolveServerList", () => { + test("lets startup auth_token credentials override a persisted same-url server", () => { + const list = resolveServerList({ + stored: [{ url: "https://server.example.test" }], + props: [ + { + type: "http", + authToken: true, + http: { + url: "https://server.example.test", + username: "opencode", + password: "secret", + }, + }, + ], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "secret", + }) + expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true) + expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test") + }) + + test("keeps persisted credentials when startup has no auth_token", () => { + const list = resolveServerList({ + stored: [ + { + url: "https://server.example.test", + username: "opencode", + password: "saved", + }, + ], + props: [{ type: "http", http: { url: "https://server.example.test" } }], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "saved", + }) + expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1204fba557..a981d99fa1 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -33,6 +33,33 @@ function isLocalHost(url: string) { if (host === "localhost" || host === "127.0.0.1") return "local" } +export function resolveServerList(input: { + props?: Array + stored: StoredServer[] +}): Array { + const servers = [ + ...input.stored.map((value) => + typeof value === "string" + ? { + type: "http" as const, + http: { url: value }, + } + : value, + ), + ...(input.props ?? []), + ] + + const deduped = new Map() + for (const value of servers) { + const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } + const key = ServerConnection.key(conn) + if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue + deduped.set(key, conn) + } + + return [...deduped.values()] +} + export namespace ServerConnection { type Base = { displayName?: string } @@ -46,6 +73,7 @@ export namespace ServerConnection { export type Http = { type: "http" http: HttpBase + authToken?: boolean } & Base export type Sidecar = { @@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) const allServers = createMemo((): Array => { - const servers = [ - ...(props.servers ?? []), - ...store.list.map((value) => - typeof value === "string" - ? { - type: "http" as const, - http: { url: value }, - } - : value, - ), - ] - - const deduped = new Map( - servers.map((value) => { - const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } - return [ServerConnection.key(conn), conn] - }), - ) - - return [...deduped.values()] + return resolveServerList({ stored: store.list, props: props.servers }) }) const [state, setState] = createStore({ @@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) if (!url_) return - const conn = { ...input, http: { ...input.http, url: url_ } } + const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } } return batch(() => { const existing = store.list.findIndex((x) => url(x) === url_) if (existing !== -1) { diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 6e07e03124..5bca1b4b7e 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,6 +1,9 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" -let getWorkspaceTerminalCacheKey: (dir: string) => string +type ServerKey = Parameters[1] + +let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string +let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let migrateTerminalState: (value: unknown) => unknown @@ -17,6 +20,7 @@ beforeAll(async () => { })) const mod = await import("./terminal") getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey + getTerminalServerScope = mod.getTerminalServerScope getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys migrateTerminalState = mod.migrateTerminalState }) @@ -25,6 +29,45 @@ describe("getWorkspaceTerminalCacheKey", () => { test("uses workspace-only directory cache key", () => { expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") }) + + test("can include a server scope", () => { + expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__") + }) +}) + +describe("getTerminalServerScope", () => { + test("preserves local server keys", () => { + expect( + getTerminalServerScope( + { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }, + "sidecar" as ServerKey, + ), + ).toBeUndefined() + expect( + getTerminalServerScope( + { type: "http", http: { url: "http://localhost:4096" } }, + "http://localhost:4096" as ServerKey, + ), + ).toBeUndefined() + expect( + getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey), + ).toBeUndefined() + }) + + test("scopes non-local server keys", () => { + expect( + getTerminalServerScope( + { type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } }, + "wsl:Debian" as ServerKey, + ), + ).toBe("wsl:Debian" as ServerKey) + expect( + getTerminalServerScope( + { type: "http", http: { url: "https://example.com" } }, + "https://example.com" as ServerKey, + ), + ).toBe("https://example.com" as ServerKey) + }) }) describe("getLegacyTerminalStorageKeys", () => { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 31d2d6e04c..f6751c3f0e 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" +import { ServerConnection, useServer } from "./server" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" @@ -82,10 +83,31 @@ export function migrateTerminalState(value: unknown) { } } -export function getWorkspaceTerminalCacheKey(dir: string) { +export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) { + if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}` return `${dir}:${WORKSPACE_KEY}` } +export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) { + if (!conn) return + if (conn.type === "sidecar" && conn.variant === "base") return + if (conn.type === "http") { + try { + const url = new URL(conn.http.url) + if ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]" + ) + return + } catch { + return key + } + } + return key +} + export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { if (!legacySessionID) return [`${dir}/terminal.v1`] return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] @@ -110,15 +132,16 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { - const key = getWorkspaceTerminalCacheKey(dir) +export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) { + const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) entry?.value.clear() } - void removePersisted(Persist.workspace(dir, "terminal"), platform) + void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform) + if (scope) return const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { for (const key of getLegacyTerminalStorageKeys(dir, id)) { @@ -130,12 +153,17 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } -function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { - const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) +function createWorkspaceTerminalSession( + sdk: ReturnType, + dir: string, + legacySessionID?: string, + scope?: string, +) { + const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID) const [store, setStore, _, ready] = persisted( { - ...Persist.workspace(dir, "terminal", legacy), + ...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy), migrate: migrateTerminalState, }, createStore<{ @@ -357,8 +385,12 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() + const server = useServer() const params = useParams() const cache = new Map() + const scope = createMemo(() => { + return getTerminalServerScope(server.current, server.key) + }) caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -382,9 +414,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, legacySessionID?: string) => { + const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => { // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. - const key = getWorkspaceTerminalCacheKey(dir) + const key = getWorkspaceTerminalCacheKey(dir, serverScope) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -393,7 +425,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope), dispose, })) @@ -402,16 +434,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope())) createEffect( on( - () => ({ dir: params.dir, id: params.id }), + () => ({ dir: params.dir, id: params.id, scope: scope() }), (next, prev) => { if (!prev?.dir) return - if (next.dir === prev.dir && next.id === prev.id) return - if (next.dir === prev.dir && next.id) return - loadWorkspace(prev.dir, prev.id).trimAll() + if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return + if (next.dir === prev.dir && next.id && next.scope === prev.scope) return + loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() }, { defer: true }, ), diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75..5115f0348a 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,11 +1,13 @@ // @refresh reload +import * as Sentry from "@sentry/solid" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" +import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -110,6 +112,13 @@ const getDefaultUrl = () => { return getCurrentUrl() } +const clearAuthToken = () => { + const params = new URLSearchParams(location.search) + if (!params.has("auth_token")) return + params.delete("auth_token") + history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash) +} + const platform: Platform = { platform: "web", version: pkg.version, @@ -125,8 +134,36 @@ const platform: Platform = { setDefaultServer: writeDefaultServerUrl, } +if (import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`, + initialScope: { + tags: { + platform: "web", + }, + }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, + }) +} + if (root instanceof HTMLElement) { - const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } + const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) + clearAuthToken() + const server: ServerConnection.Http = { + type: "http", + authToken: !!auth, + http: { + url: getCurrentUrl(), + ...auth, + }, + } render( () => ( diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 9b03d336f9..39f827eb64 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -2,6 +2,10 @@ interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" + + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string } interface ImportMeta { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 49808f3fbb..405153647a 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -402,6 +402,8 @@ export const dict = { "error.page.description": "حدث خطأ أثناء تحميل التطبيق.", "error.page.details.label": "تفاصيل الخطأ", "error.page.action.restart": "إعادة تشغيل", + "error.page.action.report": "الإبلاغ عن الخطأ", + "error.page.action.reported": "تم الإبلاغ عن الخطأ", "error.page.action.checking": "جارٍ التحقق...", "error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.updateTo": "تحديث إلى {{version}}", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 5b96ff57a6..9a3a476802 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -403,6 +403,8 @@ export const dict = { "error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.details.label": "Detalhes do Erro", "error.page.action.restart": "Reiniciar", + "error.page.action.report": "Reportar erro", + "error.page.action.reported": "Erro reportado", "error.page.action.checking": "Verificando...", "error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.updateTo": "Atualizar para {{version}}", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 98e565464b..ae34749c32 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -449,6 +449,8 @@ export const dict = { "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", "error.page.details.label": "Detalji greške", "error.page.action.restart": "Restartuj", + "error.page.action.report": "Prijavi grešku", + "error.page.action.reported": "Greška prijavljena", "error.page.action.checking": "Provjera...", "error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.updateTo": "Ažuriraj na {{version}}", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 5c1dcfcae4..370420398b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -446,6 +446,8 @@ export const dict = { "error.page.description": "Der opstod en fejl under indlæsning af applikationen.", "error.page.details.label": "Fejldetaljer", "error.page.action.restart": "Genstart", + "error.page.action.report": "Rapportér fejl", + "error.page.action.reported": "Fejl rapporteret", "error.page.action.checking": "Tjekker...", "error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.updateTo": "Opdater til {{version}}", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 68a4def36a..532ff7c08b 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -410,6 +410,8 @@ export const dict = { "error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.", "error.page.details.label": "Fehlerdetails", "error.page.action.restart": "Neustart", + "error.page.action.report": "Fehler melden", + "error.page.action.reported": "Fehler gemeldet", "error.page.action.checking": "Prüfen...", "error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.updateTo": "Auf {{version}} aktualisieren", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1d2b03db76..250d26edbe 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -465,6 +465,8 @@ export const dict = { "error.page.description": "An error occurred while loading the application.", "error.page.details.label": "Error Details", "error.page.action.restart": "Restart", + "error.page.action.report": "Report Error", + "error.page.action.reported": "Error Reported", "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4761cd2000..cde8f3ec5e 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -449,6 +449,8 @@ export const dict = { "error.page.description": "Ocurrió un error al cargar la aplicación.", "error.page.details.label": "Detalles del error", "error.page.action.restart": "Reiniciar", + "error.page.action.report": "Informar error", + "error.page.action.reported": "Error informado", "error.page.action.checking": "Comprobando...", "error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.updateTo": "Actualizar a {{version}}", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index f470cace42..6cea948b1c 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -406,6 +406,8 @@ export const dict = { "error.page.description": "Une erreur s'est produite lors du chargement de l'application.", "error.page.details.label": "Détails de l'erreur", "error.page.action.restart": "Redémarrer", + "error.page.action.report": "Signaler l'erreur", + "error.page.action.reported": "Erreur signalée", "error.page.action.checking": "Vérification...", "error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.updateTo": "Mettre à jour vers {{version}}", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 51eab3d09b..ec4d95b98e 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -402,6 +402,8 @@ export const dict = { "error.page.description": "アプリケーションの読み込み中にエラーが発生しました。", "error.page.details.label": "エラー詳細", "error.page.action.restart": "再起動", + "error.page.action.report": "エラーを報告", + "error.page.action.reported": "エラーを報告しました", "error.page.action.checking": "確認中...", "error.page.action.checkUpdates": "アップデートを確認", "error.page.action.updateTo": "{{version}}にアップデート", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 206ae23d82..6eb064244b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -401,6 +401,8 @@ export const dict = { "error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.", "error.page.details.label": "오류 세부 정보", "error.page.action.restart": "다시 시작", + "error.page.action.report": "오류 신고", + "error.page.action.reported": "오류가 신고됨", "error.page.action.checking": "확인 중...", "error.page.action.checkUpdates": "업데이트 확인", "error.page.action.updateTo": "{{version}} 버전으로 업데이트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 3014e1e5d3..a280a3dbf1 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -450,6 +450,8 @@ export const dict = { "error.page.description": "Det oppstod en feil under lasting av applikasjonen.", "error.page.details.label": "Feildetaljer", "error.page.action.restart": "Start på nytt", + "error.page.action.report": "Rapporter feil", + "error.page.action.reported": "Feil rapportert", "error.page.action.checking": "Sjekker...", "error.page.action.checkUpdates": "Se etter oppdateringer", "error.page.action.updateTo": "Oppdater til {{version}}", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index a555f09e35..eea33b7234 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -403,6 +403,8 @@ export const dict = { "error.page.description": "Wystąpił błąd podczas ładowania aplikacji.", "error.page.details.label": "Szczegóły błędu", "error.page.action.restart": "Restartuj", + "error.page.action.report": "Zgłoś błąd", + "error.page.action.reported": "Błąd zgłoszony", "error.page.action.checking": "Sprawdzanie...", "error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.updateTo": "Zaktualizuj do {{version}}", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d8a81357b6..6e455220be 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -448,6 +448,8 @@ export const dict = { "error.page.description": "Произошла ошибка при загрузке приложения.", "error.page.details.label": "Детали ошибки", "error.page.action.restart": "Перезапустить", + "error.page.action.report": "Сообщить об ошибке", + "error.page.action.reported": "Об ошибке сообщено", "error.page.action.checking": "Проверка...", "error.page.action.checkUpdates": "Проверить обновления", "error.page.action.updateTo": "Обновить до {{version}}", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index b51b9a13c3..998962db98 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -447,6 +447,8 @@ export const dict = { "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน", "error.page.details.label": "รายละเอียดข้อผิดพลาด", "error.page.action.restart": "รีสตาร์ท", + "error.page.action.report": "รายงานข้อผิดพลาด", + "error.page.action.reported": "รายงานข้อผิดพลาดแล้ว", "error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.updateTo": "อัปเดตเป็น {{version}}", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 0284e908b6..4d006a43b1 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -452,6 +452,8 @@ export const dict = { "error.page.description": "Uygulama yüklenirken bir hata oluştu.", "error.page.details.label": "Hata Detayları", "error.page.action.restart": "Yeniden Başlat", + "error.page.action.report": "Hatayı Bildir", + "error.page.action.reported": "Hata Bildirildi", "error.page.action.checking": "Kontrol ediliyor...", "error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.updateTo": "{{version}} sürümüne güncelle", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e9d0fa4715..a422a5d61d 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -452,6 +452,8 @@ export const dict = { "error.page.description": "加载应用程序时发生错误。", "error.page.details.label": "错误详情", "error.page.action.restart": "重启", + "error.page.action.report": "上报错误", + "error.page.action.reported": "错误已上报", "error.page.action.checking": "检查中...", "error.page.action.checkUpdates": "检查更新", "error.page.action.updateTo": "更新到 {{version}}", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index ff6392d259..7d894a83e0 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -445,6 +445,8 @@ export const dict = { "error.page.description": "載入應用程式時發生錯誤。", "error.page.details.label": "錯誤詳情", "error.page.action.restart": "重新啟動", + "error.page.action.report": "回報錯誤", + "error.page.action.reported": "已回報錯誤", "error.page.action.checking": "檢查中...", "error.page.action.checkUpdates": "檢查更新", "error.page.action.updateTo": "更新到 {{version}}", diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index ba0045ec93..5f3d7baa68 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -1,7 +1,8 @@ import { TextField } from "@opencode-ai/ui/text-field" +import * as Sentry from "@sentry/solid" import { Logo } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" -import { Component, Show } from "solid-js" +import { Component, createSignal, Show } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" @@ -270,10 +271,27 @@ export const ErrorPage: Component = (props) => { label={language.t("error.page.details.label")} hideLabel /> -
+
+ + {(_) => { + const [reported, setReported] = createSignal(false) + return ( + + ) + }} + s.id), platform, + getTerminalServerScope(server.current, server.key), ) await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 2c2d9817f0..d7868d9170 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -37,6 +37,7 @@ export function TerminalPanel() { const [store, setStore] = createStore({ autoCreated: false, activeDraggable: undefined as string | undefined, + recovered: {} as Record, view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) @@ -145,6 +146,21 @@ export function TerminalPanel() { const all = terminal.all const ids = createMemo(() => all().map((pty) => pty.id)) + const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => { + if (store.recovered[key]) return + setStore("recovered", key, true) + void clone(id) + } + + const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => { + return String(pty.titleNumber || pty.title || pty.id) + } + + const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => { + setStore("recovered", key, false) + trim(id) + } + const handleTerminalDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -280,9 +296,9 @@ export function TerminalPanel() { ops.trim(id)} + onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onCleanup={ops.update} - onConnectError={() => ops.clone(id)} + onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} />
)} diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts new file mode 100644 index 0000000000..4666b7d6d0 --- /dev/null +++ b/packages/app/src/utils/server.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { authFromToken, authTokenFromCredentials } from "./server" + +describe("authFromToken", () => { + test("decodes basic auth credentials from auth_token", () => { + expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" }) + }) + + test("defaults blank username to opencode", () => { + expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" }) + }) + + test("ignores malformed tokens", () => { + expect(authFromToken("not base64")).toBeUndefined() + expect(authFromToken(btoa("missing-separator"))).toBeUndefined() + }) +}) + +describe("authTokenFromCredentials", () => { + test("encodes credentials with the default username", () => { + expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index ae849b71ee..603784e4d4 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,21 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function authTokenFromCredentials(input: { username?: string; password: string }) { + return btoa(`${input.username ?? "opencode"}:${input.password}`) +} + +export function authFromToken(token: string | null) { + const decoded = decode64(token ?? undefined) + if (!decoded) return + const separator = decoded.indexOf(":") + if (separator === -1) return + return { + username: decoded.slice(0, separator) || "opencode", + password: decoded.slice(separator + 1), + } +} export function createSdkForServer({ server, @@ -10,7 +26,7 @@ export function createSdkForServer({ const auth = (() => { if (!server.password) return return { - Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`, + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, } })() diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts new file mode 100644 index 0000000000..5fa1506b1e --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test" +import { terminalWebSocketURL } from "./terminal-websocket-url" + +describe("terminalWebSocketURL", () => { + test("uses query auth without embedding credentials in websocket URL", () => { + const url = terminalWebSocketURL({ + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + sameOrigin: false, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) + + test("omits query auth for same-origin saved credentials", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.has("auth_token")).toBe(false) + }) + + test("uses query auth for same-origin credentials from auth_token", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + authToken: true, + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts new file mode 100644 index 0000000000..06facdc7d2 --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -0,0 +1,28 @@ +import { authTokenFromCredentials } from "@/utils/server" + +export function terminalWebSocketURL(input: { + url: string + id: string + directory: string + cursor: number + ticket?: string + sameOrigin?: boolean + username?: string + password?: string + authToken?: boolean +}) { + const next = new URL(`${input.url}/pty/${input.id}/connect`) + next.searchParams.set("directory", input.directory) + next.searchParams.set("cursor", String(input.cursor)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + next.searchParams.set("ticket", input.ticket) + return next + } + if (input.password && (!input.sameOrigin || input.authToken)) + next.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: input.username, password: input.password }), + ) + return next +} diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 6a29ae6345..8df324ddc9 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,8 +1,26 @@ +import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "vite" import desktopPlugin from "./vite" +const sentry = + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + telemetry: false, + release: { + name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, + }, + sourcemaps: { + assets: "./dist/**", + filesToDeleteAfterUpload: "./dist/**/*.map", + }, + }) + : false + export default defineConfig({ - plugins: [desktopPlugin] as any, + plugins: [desktopPlugin, sentry] as any, server: { host: "0.0.0.0", allowedHosts: true, @@ -10,6 +28,6 @@ export default defineConfig({ }, build: { target: "esnext", - // sourcemap: true, + sourcemap: true, }, }) diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 49b4cf48cf..fb2e71d22d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index b486acb99d..7a4b5ef65e 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start" import type { DownloadPlatform } from "../types" const prodAssetNames: Record = { - "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", - "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", - "windows-x64-nsis": "opencode-desktop-windows-x64.exe", + "darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg", + "darwin-x64-dmg": "opencode-desktop-mac-x64.dmg", + "windows-x64-nsis": "opencode-desktop-win-x64.exe", "linux-x64-deb": "opencode-desktop-linux-amd64.deb", - "linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage", + "linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage", "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", } satisfies Record @@ -32,13 +32,6 @@ export async function GET({ params: { platform, channel } }: APIEvent) { const resp = await fetch( `https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`, - { - cf: { - // in case gh releases has rate limits - cacheTtl: 60 * 5, - cacheEverything: true, - }, - } as any, ) const downloadName = downloadNames[platform] diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index c15197b6e3..7f36246ee5 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -141,7 +141,10 @@ export async function handler( ) validateModelSettings(billingSource, authInfo) updateProviderKey(authInfo, providerInfo) - logger.metric({ provider: providerInfo.id }) + logger.metric({ + provider: providerInfo.id, + "provider.model": providerInfo.model, + }) const startTimestamp = Date.now() const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream) @@ -149,12 +152,25 @@ export async function handler( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), model: providerInfo.model, - ...providerInfo.payloadModifier, - ...Object.fromEntries( - Object.entries(providerInfo.payloadMappings ?? {}) - .map(([k, v]) => [k, input.request.headers.get(v)]) - .filter(([_k, v]) => !!v), - ), + ...(() => { + const replacer = (obj: Record): Record => + Object.fromEntries( + Object.entries(obj).flatMap(([k, v]) => { + if (Array.isArray(v)) return [[k, v]] + if (typeof v === "object") return [[k, replacer(v)]] + if (typeof v === "string") { + if (v === "$ip") return [[k, ip]] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v.startsWith("$header.")) { + const headerValue = input.request.headers.get(v.slice(8)) + return headerValue ? [[k, headerValue]] : [] + } + } + return [[k, v]] + }), + ) + return replacer(providerInfo.payloadModifier ?? {}) + })(), }), ) logger.debug("REQUEST URL: " + reqUrl) @@ -514,7 +530,6 @@ export async function handler( reqModel, providerModel: modelProvider.model, adjustCacheUsage: providerProps.adjustCacheUsage, - safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined, workspaceID: authInfo?.workspaceID, } if (format === "anthropic") return anthropicHelper(opts) @@ -904,6 +919,13 @@ export async function handler( "tokens.cache_read": cacheReadTokens, "tokens.cache_write_5m": cacheWrite5mTokens, "tokens.cache_write_1h": cacheWrite1hTokens, + "cost.input.microcents": centsToMicroCents(inputCost), + "cost.output.microcents": centsToMicroCents(outputCost), + "cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined, + "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, + "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, + "cost.total.microcents": centsToMicroCents(totalCostInCent), + // deprecated - remove after May 20, 2026 "cost.input": Math.round(inputCost), "cost.output": Math.round(outputCost), "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 97b0abc64f..e6dedb1a4b 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -23,7 +23,7 @@ type Usage = { } } -export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({ +export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ format: "oa-compat", modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { @@ -34,7 +34,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif return { ...body, ...(body.stream ? { stream_options: { include_usage: true } } : {}), - ...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}), } }, createBinaryStreamDecoder: () => undefined, diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index bee1e01ec0..5d61a903ef 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -18,10 +18,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { headers.set("authorization", `Bearer ${apiKey}`) }, - modifyBody: (body: Record) => ({ - ...body, - ...(workspaceID ? { safety_identifier: workspaceID } : {}), - }), + modifyBody: (body: Record) => body, createBinaryStreamDecoder: () => undefined, streamSeparator: "\n\n", createUsageParser: () => { diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index ffb23f54c9..86446bfd85 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -37,7 +37,6 @@ export type ProviderHelper = (input: { reqModel: string providerModel: string adjustCacheUsage?: boolean - safetyIdentifier?: string workspaceID?: string }) => { format: ZenData.Format diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3e1bd07f35..7301b23e5c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.30", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 6281382d65..dc3febe055 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -40,7 +40,6 @@ export namespace ZenData { disabled: z.boolean().optional(), storeModel: z.string().optional(), payloadModifier: z.record(z.string(), z.any()).optional(), - safetyIdentifier: z.boolean().optional(), }), ), }) diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 5e450446ae..06fb0affd0 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.30", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 4839bdc7ee..674fc55fd5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.30", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 428419434e..e90ab7628a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.30", + "version": "1.14.35", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 44346be8f9..8a1cc3a08f 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -24,6 +24,7 @@ export namespace AppFileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect readonly existsSafe: (path: string) => Effect.Effect + readonly readFileStringSafe: (path: string) => Effect.Effect readonly readJson: (path: string) => Effect.Effect readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect readonly ensureDir: (path: string) => Effect.Effect @@ -47,6 +48,12 @@ export namespace AppFileSystem { return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) }) + const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) { + return yield* fs + .readFileString(path) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + }) + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) return info?.type === "Directory" @@ -163,6 +170,7 @@ export namespace AppFileSystem { return Service.of({ ...fs, existsSafe, + readFileStringSafe, isDir, isFile, readDirectoryEntries, diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a3b8133b64..0daae55800 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -81,8 +86,16 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], - OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"), + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + OPENCODE_EXPERIMENTAL_HTTPAPI: + truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || + (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), + OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 1acc3f47f1..6560d308c1 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -71,6 +71,8 @@ export const layer = Layer.effect( Effect.sync(() => Service.of(make())), ) +export const defaultLayer = layer + export const layerWith = (input: Partial) => Layer.effect( Service, diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 92e4042768..8dac8faf01 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -120,13 +120,17 @@ export const layer = Layer.effect( } })() - if (yield* afs.existsSafe(dir)) { + if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { return resolveEntryPoint(name, path.join(dir, "node_modules", name)) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + if (!first) { + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + if (Option.isSome(result.entrypoint)) return result + return yield* new InstallFailedError({ add: [pkg], dir }) + } return resolveEntryPoint(first.name, first.path) }, Effect.scoped) diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index a61c15f7a7..e1962aed4c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index b77f4e356f..1d9405333d 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -65,6 +65,34 @@ describe("AppFileSystem", () => { ) }) + describe("readFileStringSafe", () => { + it( + "returns file contents when file exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "hello") + + const result = yield* fs.readFileStringSafe(file) + expect(result).toBe("hello") + }), + ) + + it( + "returns undefined for missing file (NotFound)", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + + const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt")) + expect(result).toBeUndefined() + }), + ) + }) + describe("readJson / writeJson", () => { it( "round-trips JSON data", diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts new file mode 100644 index 0000000000..4e13e88424 --- /dev/null +++ b/packages/core/test/global.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Global } from "@opencode-ai/core/global" + +describe("global paths", () => { + test("tmp path is under the system temp directory", () => { + expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode")) + expect(Global.make().tmp).toBe(Global.Path.tmp) + }) + + test("tmp path is created on module load", async () => { + expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) + }) +}) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 3e94a08692..3d0767aaff 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,7 +1,12 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record) => }), ) +const npmLayer = (cache: string) => + Npm.layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), + Layer.provide(NodeFileSystem.layer), + ) + describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") @@ -29,6 +42,28 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.add", () => { + test("reifies when package cache directory exists without the package installed", async () => { + await using tmp = await tmpdir() + await fs.mkdir(path.join(tmp.path, "fixture-provider")) + await writePackage(path.join(tmp.path, "fixture-provider"), { + name: "fixture-provider", + main: "index.js", + }) + await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + + const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` + await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) + + const entry = await Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(spec) + }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) + + expect(Option.isSome(entry.entrypoint)).toBe(true) + }) +}) + describe("Npm.install", () => { test("respects omit from project .npmrc", async () => { await using tmp = await tmpdir() diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index fa088cd65d..da734dc81d 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -27,7 +27,7 @@ const channel = (() => { })() const getBase = (): Configuration => ({ - artifactName: "opencode-electron-${os}-${arch}.${ext}", + artifactName: "opencode-desktop-${os}-${arch}.${ext}", directories: { output: "dist", buildResources: "resources", diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index f28c7b6c18..a352e03fdd 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -1,3 +1,4 @@ +import { sentryVitePlugin } from "@sentry/vite-plugin" import { defineConfig } from "electron-vite" import appPlugin from "@opencode-ai/app/vite" import * as fs from "node:fs/promises" @@ -12,6 +13,23 @@ const OPENCODE_SERVER_DIST = "../opencode/dist/node" const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}` +const sentry = + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT + ? sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + telemetry: false, + release: { + name: process.env.SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE, + }, + sourcemaps: { + assets: "./out/renderer/**", + filesToDeleteAfterUpload: "./out/renderer/**/*.map", + }, + }) + : false + export default defineConfig({ main: { define: { @@ -61,13 +79,14 @@ export default defineConfig({ }, }, renderer: { - plugins: [appPlugin], + plugins: [appPlugin, sentry], publicDir: "../../../app/public", root: "src/renderer", define: { "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), }, build: { + sourcemap: true, rollupOptions: { input: { main: "src/renderer/index.html", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 2586159514..ba981e637a 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", @@ -38,6 +38,8 @@ "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", + "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index c9f16606ae..af7fd42583 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -74,6 +74,7 @@ setupApp() function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") if (!app.requestSingleInstanceLock()) { app.quit() diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 8dbca8eea1..2413613730 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -11,7 +11,7 @@ import type { WslConfig, } from "../preload/types" import { getStore } from "./store" -import { setTitlebar } from "./windows" +import { setTitlebar, updateTitlebar } from "./windows" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -183,7 +183,12 @@ export function registerIpcHandlers(deps: Deps) { }) ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) + ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { + event.sender.setZoomFactor(factor) + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + updateTitlebar(win) + }) ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 337e1ca0bc..387e793b0e 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -21,6 +21,8 @@ protocol.registerSchemesAsPrivileged([ ]) let backgroundColor: string | undefined +const titlebarThemes = new WeakMap>() +const titlebarHeight = 40 export function setBackgroundColor(color: string) { backgroundColor = color @@ -43,18 +45,23 @@ function tone() { return nativeTheme.shouldUseDarkColors ? "dark" : "light" } -function overlay(theme: Partial = {}) { +function overlay(theme: Partial = {}, zoom = 1) { const mode = theme.mode ?? tone() return { color: "#00000000", symbolColor: mode === "dark" ? "white" : "black", - height: 40, + height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)), } } export function setTitlebar(win: BrowserWindow, theme: Partial = {}) { + titlebarThemes.set(win, theme) + updateTitlebar(win) +} + +export function updateTitlebar(win: BrowserWindow) { if (process.platform !== "win32") return - win.setTitleBarOverlay(overlay(theme)) + win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor())) } export function setDockIcon() { @@ -188,6 +195,7 @@ function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { win.webContents.setZoomFactor(1) + updateTitlebar(win) }) } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 91ea1ae077..52dd6bef8c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -14,6 +14,7 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" +import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js" @@ -29,6 +30,25 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) } +if (import.meta.env.VITE_SENTRY_DSN) { + Sentry.init({ + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE, + release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop-electron@${pkg.version}`, + initialScope: { + tags: { + platform: "desktop-electron", + }, + }, + integrations: (integrations) => { + return integrations.filter( + (i) => + i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"), + ) + }, + }) +} + void initI18n() const deepLinkEvent = "opencode:deep-link" diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a35..cb4b5a4481 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -12,6 +12,7 @@ const OS_NAME = (() => { })() const [webviewZoom, setWebviewZoom] = createSignal(1) +let requestedZoom = 1 const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 @@ -19,20 +20,36 @@ const MIN_ZOOM_LEVEL = 0.2 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) const applyZoom = (next: number) => { - setWebviewZoom(next) - void window.api.setZoomFactor(next) + requestedZoom = next + void window.api + .setZoomFactor(next) + .then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }) + .catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) } window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return - let newZoom = webviewZoom() - - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 - - applyZoom(clamp(newZoom)) + if (event.key === "-") { + event.preventDefault() + applyZoom(clamp(requestedZoom - 0.2)) + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + applyZoom(clamp(requestedZoom + 0.2)) + return + } + if (event.key === "0") { + event.preventDefault() + applyZoom(1) + } }) export { webviewZoom } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ff81acaaf3..e60320300a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { @@ -15,6 +15,7 @@ "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@sentry/solid": "catalog:", "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", @@ -35,6 +36,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@sentry/vite-plugin": "catalog:", "@tauri-apps/cli": "^2", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts index 855c6a3878..cb0f26b94d 100644 --- a/packages/desktop/scripts/finalize-latest-json.ts +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun -import { Buffer } from "node:buffer" import { $ } from "bun" +import path from "node:path" +import { parseArgs } from "node:util" const { values } = parseArgs({ args: Bun.argv.slice(2), @@ -12,8 +13,6 @@ const { values } = parseArgs({ const dryRun = values["dry-run"] -import { parseArgs } from "node:util" - const repo = process.env.GH_REPO if (!repo) throw new Error("GH_REPO is required") @@ -23,20 +22,22 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required") const version = process.env.OPENCODE_VERSION if (!version) throw new Error("OPENCODE_VERSION is required") +const dir = process.env.LATEST_YML_DIR +if (!dir) throw new Error("LATEST_YML_DIR is required") +const root = dir + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") -const apiHeaders = { - Authorization: `token ${token}`, - Accept: "application/vnd.github+json", -} - -const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { - headers: apiHeaders, +const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + }, }) -if (!releaseRes.ok) { - throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`) +if (!rel.ok) { + throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`) } type Asset = { @@ -45,115 +46,169 @@ type Asset = { } type Release = { - tag_name?: string assets?: Asset[] } -const release = (await releaseRes.json()) as Release -const assets = release.assets ?? [] -const assetByName = new Map(assets.map((asset) => [asset.name, asset])) +const assets = ((await rel.json()) as Release).assets ?? [] +const amap = new Map(assets.map((item) => [item.name, item])) -const latestAsset = assetByName.get("latest.json") -if (!latestAsset) { - console.log("latest.json not found, skipping tauri finalization") - process.exit(0) +type Item = { + url: string } -const latestRes = await fetch(latestAsset.url, { - headers: { - Authorization: `token ${token}`, - Accept: "application/octet-stream", - }, -}) - -if (!latestRes.ok) { - throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`) +type Yml = { + version: string + files: Item[] } -const latestText = new TextDecoder().decode(await latestRes.arrayBuffer()) -const latest = JSON.parse(latestText) -const base = { ...latest } -delete base.platforms +function parse(text: string): Yml { + const lines = text.split("\n") + let version = "" + const files: Item[] = [] + let url = "" -const fetchSignature = async (asset: Asset) => { - const res = await fetch(asset.url, { + const flush = () => { + if (!url) return + files.push({ url }) + url = "" + } + + for (const line of lines) { + const trim = line.trim() + if (line.startsWith("version:")) { + version = line.slice("version:".length).trim() + continue + } + if (trim.startsWith("- url:")) { + flush() + url = trim.slice("- url:".length).trim() + continue + } + const indented = line.startsWith(" ") || line.startsWith("\t") + if (!indented) flush() + } + flush() + + return { version, files } +} + +async function read(sub: string, file: string) { + const item = Bun.file(path.join(root, sub, file)) + if (!(await item.exists())) return undefined + return parse(await item.text()) +} + +function pick(list: Item[], exts: string[]) { + for (const ext of exts) { + const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext)) + if (found) return found.url + } +} + +function link(raw: string) { + if (raw.startsWith("https://") || raw.startsWith("http://")) return raw + return `https://github.com/${repo}/releases/download/v${version}/${raw}` +} + +async function sign(url: string, key: string) { + const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key) + const asset = amap.get(name) + const res = await fetch(asset?.url ?? url, { headers: { Authorization: `token ${token}`, - Accept: "application/octet-stream", + ...(asset ? { Accept: "application/octet-stream" } : {}), }, }) - if (!res.ok) { - throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`) + throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`) } - return Buffer.from(await res.arrayBuffer()).toString() + const tmp = process.env.RUNNER_TEMP ?? "/tmp" + const file = path.join(tmp, name) + await Bun.write(file, await res.arrayBuffer()) + await $`bunx @tauri-apps/cli signer sign ${file}` + const sigFile = Bun.file(`${file}.sig`) + if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`) + return (await sigFile.text()).trim() } -const entries: Record = {} -const add = (key: string, asset: Asset, signature: string) => { - if (entries[key]) return - entries[key] = { - url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`, - signature, - } +const add = async (data: Record, key: string, raw: string | undefined) => { + if (!raw) return + if (data[key]) return + const url = link(raw) + data[key] = { url, signature: await sign(url, key) } } -const targets = [ - { key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" }, - { key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" }, - { key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" }, - { key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" }, - { key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" }, - { key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" }, - { key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" }, - { - key: "darwin-aarch64-app", - asset: "opencode-desktop-darwin-aarch64.app.tar.gz", - }, -] - -for (const target of targets) { - const asset = assetByName.get(target.asset) - if (!asset) continue - - const sig = assetByName.get(`${target.asset}.sig`) - if (!sig) continue - - const signature = await fetchSignature(sig) - add(target.key, asset, signature) +const alias = (data: Record, key: string, src: string) => { + if (data[key]) return + if (!data[src]) return + data[key] = data[src] } -const alias = (key: string, source: string) => { - if (entries[key]) return - const entry = entries[source] - if (!entry) return - entries[key] = entry -} +const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml") +const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml") +const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml") +const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml") +const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml") +const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml") -alias("linux-x86_64", "linux-x86_64-deb") -alias("linux-aarch64", "linux-aarch64-deb") -alias("windows-aarch64", "windows-aarch64-nsis") -alias("windows-x86_64", "windows-x86_64-nsis") -alias("darwin-x86_64", "darwin-x86_64-app") -alias("darwin-aarch64", "darwin-aarch64-app") +const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version +if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`) + +const out: Record = {} + +const winxexe = pick(winx?.files ?? [], [".exe"]) +const winaexe = pick(wina?.files ?? [], [".exe"]) + +const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz" +const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz" + +const linxDeb = pick(linx?.files ?? [], [".deb"]) +const linxRpm = pick(linx?.files ?? [], [".rpm"]) +const linxAppImage = pick(linx?.files ?? [], [".appimage"]) +const linaDeb = pick(lina?.files ?? [], [".deb"]) +const linaRpm = pick(lina?.files ?? [], [".rpm"]) +const linaAppImage = pick(lina?.files ?? [], [".appimage"]) + +await add(out, "windows-x86_64-nsis", winxexe) +await add(out, "windows-aarch64-nsis", winaexe) +await add(out, "darwin-x86_64-app", macxTarGz) +await add(out, "darwin-aarch64-app", macaTarGz) + +await add(out, "linux-x86_64-deb", linxDeb) +await add(out, "linux-x86_64-rpm", linxRpm) +await add(out, "linux-x86_64-appimage", linxAppImage) +await add(out, "linux-aarch64-deb", linaDeb) +await add(out, "linux-aarch64-rpm", linaRpm) +await add(out, "linux-aarch64-appimage", linaAppImage) + +alias(out, "windows-x86_64", "windows-x86_64-nsis") +alias(out, "windows-aarch64", "windows-aarch64-nsis") +alias(out, "darwin-x86_64", "darwin-x86_64-app") +alias(out, "darwin-aarch64", "darwin-aarch64-app") +alias(out, "linux-x86_64", "linux-x86_64-deb") +alias(out, "linux-aarch64", "linux-aarch64-deb") const platforms = Object.fromEntries( - Object.keys(entries) + Object.keys(out) .sort() - .map((key) => [key, entries[key]]), + .map((key) => [key, out[key]]), ) -const output = { - ...base, + +if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts") + +const data = { + version, + notes: "", + pub_date: new Date().toISOString(), platforms, } -const dir = process.env.RUNNER_TEMP ?? "/tmp" -const file = `${dir}/latest.json` -await Bun.write(file, JSON.stringify(output, null, 2)) +const tmp = process.env.RUNNER_TEMP ?? "/tmp" +const file = path.join(tmp, "latest.json") +await Bun.write(file, JSON.stringify(data, null, 2)) -const tag = release.tag_name -if (!tag) throw new Error("Release tag not found") +const tag = `v${version}` if (dryRun) { console.log(`dry-run: wrote latest.json for ${tag} to ${file}`) diff --git a/packages/desktop/src/env.d.ts b/packages/desktop/src/env.d.ts new file mode 100644 index 0000000000..aff0168422 --- /dev/null +++ b/packages/desktop/src/env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + readonly VITE_SENTRY_DSN?: string + readonly VITE_SENTRY_ENVIRONMENT?: string + readonly VITE_SENTRY_RELEASE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index a760cb4091..1a0da014dd 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -14,6 +14,7 @@ import { ServerConnection, useCommand, } from "@opencode-ai/app" +import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" import { readImage } from "@tauri-apps/plugin-clipboard-manager" diff --git a/packages/desktop/vite.config.ts b/packages/desktop/vite.config.ts index 62c3a099ad..e8f8f8465d 100644 --- a/packages/desktop/vite.config.ts +++ b/packages/desktop/vite.config.ts @@ -15,9 +15,9 @@ export default defineConfig({ // Improves production stack traces keepNames: true, }, - // build: { - // sourcemap: true, - // }, + build: { + sourcemap: true, + }, // 2. tauri expects a fixed port, fail if that port is not available server: { port: 1420, diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 98a5ddd83f..dce25e204d 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.30", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 80c73f876b..775f826d4c 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.30" +version = "1.14.35" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.30/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index bce26ed57f..1039677b52 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.30", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql new file mode 100644 index 0000000000..d5efe5f9e8 --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_message` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `type` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint +CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint +CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint +CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint +DROP TABLE `session_entry`; \ No newline at end of file diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json new file mode 100644 index 0000000000..a237b4156e --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -0,0 +1,1409 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "61f807f9-6398-4067-be05-804acc2561bc", + "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index d79324fedf..740ba0e254 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,7 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"], "ddl": [ { "name": "account_state", @@ -37,7 +37,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +598,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +608,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +618,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +628,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +638,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +648,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -1112,9 +1112,9 @@ "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { "columns": ["project_id"], @@ -1226,8 +1226,8 @@ { "columns": ["id"], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { @@ -1322,9 +1322,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1340,9 +1340,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1354,9 +1354,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/opencode/migration/20260501142318_next_venus/migration.sql new file mode 100644 index 0000000000..e0ffe7823c --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint +ALTER TABLE `session` ADD `model` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json new file mode 100644 index 0000000000..1eb0cf0b07 --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -0,0 +1,1439 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql new file mode 100644 index 0000000000..3bdf2b85e9 --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `event_sequence` ADD `owner_id` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json new file mode 100644 index 0000000000..4f6ebe00c0 --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -0,0 +1,1429 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "27114226-085b-421a-9a40-29b88747e29a", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cf2e574f51..bafa532de7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.30", + "version": "1.14.35", "name": "opencode", "type": "module", "license": "MIT", @@ -37,6 +37,11 @@ "bun": "./src/server/adapter.bun.ts", "node": "./src/server/adapter.node.ts", "default": "./src/server/adapter.bun.ts" + }, + "#httpapi-server": { + "bun": "./src/server/httpapi-server.node.ts", + "node": "./src/server/httpapi-server.node.ts", + "default": "./src/server/httpapi-server.node.ts" } }, "devDependencies": { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 35812f953d..2f2edb4ff5 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => { await $`bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) .sort() const imports = files.map((file, i) => { const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts new file mode 100644 index 0000000000..771e1e417e --- /dev/null +++ b/packages/opencode/script/httpapi-exercise.ts @@ -0,0 +1,2014 @@ +/** + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. + * + * The script intentionally isolates `OPENCODE_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. + */ +import { Cause, ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { TestLLMServer } from "../test/lib/llm-server" +import type { Config } from "../src/config/config" +import { MessageID, PartID, type SessionID } from "../src/session/schema" +import { ModelID, ProviderID } from "../src/provider/schema" +import type { MessageV2 } from "../src/session/message-v2" +import type { Worktree } from "../src/worktree" +import type { Project } from "../src/project/project" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL +const exerciseGlobalRoot = + process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.OPENCODE_DISABLE_SHARE = "true" +const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB +const exerciseDatabasePath = + process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.OPENCODE_DB = exerciseDatabasePath +Flag.OPENCODE_DB = exerciseDatabasePath + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const +const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +type Method = (typeof Methods)[number] +type OpenApiMethod = (typeof OpenApiMethods)[number] +type Mode = "effect" | "parity" | "coverage" +type Backend = "effect" | "legacy" +type Comparison = "none" | "status" | "json" +type CaptureMode = "full" | "stream" +type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +type OpenApiSpec = { paths?: Record>> } +type JsonObject = Record + +type Options = { + mode: Mode + include: string | undefined + failOnMissing: boolean + failOnSkip: boolean +} + +type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +type CallResult = { + status: number + contentType: string + body: unknown + text: string +} + +type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +type SeededContext = ScenarioContext & { + state: S +} + +type Scenario = ActiveScenario | TodoScenario +type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean +} + +/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ +type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + capture: CaptureMode + mutates: boolean + reset: boolean +} +type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} +type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +type TodoInfo = { content: string; status: string; priority: string } +type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +type Runtime = { + PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../src/server/server"))["Server"] + AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../src/session/session"))["Session"] + Todo: (typeof import("../src/session/todo"))["Todo"] + Worktree: (typeof import("../src/worktree"))["Worktree"] + Project: (typeof import("../src/project/project"))["Project"] + Tui: typeof import("../src/server/shared/tui-control") + disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../src/server/routes/instance/httpapi/server") + const server = await import("../src/server/server") + const appRuntime = await import("../src/effect/app-runtime") + const instanceRef = await import("../src/effect/instance-ref") + const instance = await import("../src/project/instance") + const instanceStore = await import("../src/project/instance-store") + const session = await import("../src/session/session") + const todo = await import("../src/session/todo") + const worktree = await import("../src/worktree") + const project = await import("../src/project/project") + const tui = await import("../src/server/shared/tui-control") + const fixture = await import("../test/fixture/fixture") + const db = await import("../test/fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string) { + this.state = { + method, + path, + name, + project: { git: true }, + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + capture: "full", + mutates: false, + reset: true, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + } + } +} + +const http = { + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), +} + +const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +function route(template: string, params: Record) { + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) +} + +const scenarios: Scenario[] = [ + http + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http + .get("/global/event", "global.event") + .global() + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), + http.get("/global/config", "global.config.get").global().json(), + http + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), + http.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") + }), + http.get("/vcs", "vcs.get").json(), + http + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), + http.get("/command", "command.list").json(200, array, "status"), + http.get("/agent", "app.agents").json(200, array, "status"), + http.get("/skill", "app.skills").json(200, array, "status"), + http.get("/lsp", "lsp.status").json(200, array), + http.get("/formatter", "formatter.status").json(200, array), + http.get("/config", "config.get").json(200, undefined, "status"), + http + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), + http + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.get("/config/providers", "config.providers").json(), + http.get("/project", "project.list").json(200, array, "status"), + http.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), + http + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), + http + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), + http.get("/provider", "provider.list").json(), + http.get("/provider/auth", "provider.auth").json(), + http + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http.get("/permission", "permission.list").json(200, array), + http + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) + .status(400), + http + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.get("/question", "question.list").json(200, array), + http + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) + .status(400), + http + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.get("/file/status", "file.status").json(200, array), + http + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) + .json(200, array), + http + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/event", "event.stream") + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), + http.get("/mcp", "mcp.status").json(), + http + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), + http + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) + .status(400), + http + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), + http + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), + http + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) + .status(400), + http + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.get("/pty/shells", "pty.shells").json(200, array), + http.get("/pty", "pty.list").json(200, array), + http + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), + http + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.get("/experimental/console", "experimental.console.get").json(), + http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) + .status(400, undefined, "none"), + http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(200), + http + .post("/experimental/workspace/warp", "experimental.workspace.warp") + .at((ctx) => ({ + path: "/experimental/workspace/warp", + headers: ctx.headers(), + body: {}, + })) + .status(400), + http + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) + .json(200, array, "status"), + http.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.get("/experimental/worktree", "worktree.list").json(200, array), + http + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), + http + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.get("/experimental/session", "experimental.session.list").json(200, array), + http.get("/experimental/resource", "experimental.resource.list").json(), + http + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), + http + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) + }), + http + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), + http + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(404), + http + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), + http + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) + .status(400), + http + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) + }), + http + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) + }), + http + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), + http + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), + http + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), + http + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- script/httpapi-exercise.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), + http + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), + http + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), + http + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), + http + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http + .post("/global/upgrade", "global.upgrade") + .global() + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), +] + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) + const selected = scenarios.filter((scenario) => matches(options, scenario)) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) + + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) +}) + +function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend("effect", scenario) + const legacy = yield* runBackend("legacy", scenario) + yield* compare(scenario, effect, legacy) + }) + } + + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const effect = yield* call("effect", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, effect) + if (options.mode === "parity" && scenario.compare !== "none") { + const legacy = yield* call("legacy", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, legacy) + yield* compare(scenario, effect, legacy) + } + }), + ) +} + +function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const result = yield* call(backend, scenario, ctx) + yield* scenario.expect(ctx, ctx.state, result) + return result + }), + ) +} + +function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { + return Effect.acquireRelease( + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((context) => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), + ...extra, + }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} + +function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { + return Effect.promise(async () => + capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + ) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = undefined + if (appCache[backend]) return appCache[backend] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache.legacy = { + request: (input, init) => legacy.request(input, init), + }) + } + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), + ), + ), + ), + { disableLogger: true }, + ).handler + return (appCache.effect = { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} + +const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) + } + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) + +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) + +function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort() +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + } +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) +} + +function printHeader( + options: Options, + effectRoutes: string[], + honoRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], +) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) + console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + ) + console.log("") +} + +function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + continue + } + if (result.status === "skip") { + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) + continue + } + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) +} + +function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 8eda0595db..99b7f1b156 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -198,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `project` | `bridged` | list, current, git init, update | | `file` | `bridged` partial | find text/file/symbol, list/content/status | | `mcp` | `bridged` | status, add, OAuth, connect/disconnect | -| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore | +| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | | top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | | `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | @@ -290,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t ### Workspace Routes -- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors. +- [x] `GET /experimental/workspace/adapter` - list workspace adapters. - [x] `POST /experimental/workspace` - create workspace. - [x] `GET /experimental/workspace` - list workspaces. - [x] `GET /experimental/workspace/status` - workspace status. diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index c4f9769224..e755457e61 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -353,7 +353,7 @@ piecewise. - [ ] `src/cli/cmd/tui/event.ts` - [ ] `src/cli/ui.ts` - [ ] `src/command/index.ts` -- [x] `src/control-plane/adaptors/worktree.ts` +- [x] `src/control-plane/adapters/worktree.ts` - [x] `src/control-plane/types.ts` - [x] `src/control-plane/workspace.ts` - [ ] `src/file/index.ts` diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index af16cba114..d66c1b2583 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -129,7 +130,7 @@ async function sendUsageUpdate( }) } -export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) { +export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { return { create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { return new Agent(connection, fullConfig) @@ -144,7 +145,7 @@ export class Agent implements ACPAgent { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -283,16 +284,16 @@ export class Agent implements ACPAgent { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (part.tool === ShellID.ToolID) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -311,7 +312,7 @@ export class Agent implements ACPAgent { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -342,7 +343,7 @@ export class Agent implements ACPAgent { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -423,7 +424,7 @@ export class Agent implements ACPAgent { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -837,10 +838,10 @@ export class Agent implements ACPAgent { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -871,7 +872,7 @@ export class Agent implements ACPAgent { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -951,7 +952,7 @@ export class Agent implements ACPAgent { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + private shellOutput(part: ToolPart) { + if (part.tool !== ShellID.ToolID) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1549,9 +1550,11 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + switch (tool) { - case "bash": + case ShellID.ToolID: return "execute" + case "webfetch": return "fetch" @@ -1576,6 +1579,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + switch (tool) { case "read": case "edit": @@ -1584,7 +1588,7 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": + case ShellID.ToolID: return [] default: return [] diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index cf9fcfbeec..3250c166ab 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -24,6 +24,7 @@ export function payloads() { .map(([type, def]) => { return z .object({ + id: z.string(), type: z.literal(type), properties: zodObject(def.properties), }) @@ -39,6 +40,7 @@ export function effectPayloads() { .entries() .map(([type, def]) => Schema.Struct({ + id: Schema.String, type: Schema.Literal(type), properties: def.properties, }).annotate({ identifier: `Event.${type}` }), diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index b5392a81b9..3cfd453624 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "events" +import { Identifier } from "@/id/id" export type GlobalEvent = { directory?: string @@ -7,6 +8,15 @@ export type GlobalEvent = { payload: any } -export const GlobalBus = new EventEmitter<{ +class GlobalBusEmitter extends EventEmitter<{ event: [GlobalEvent] -}>() +}> { + override emit(eventName: "event", event: GlobalEvent): boolean { + if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { + event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") + } + return super.emit(eventName, event) + } +} + +export const GlobalBus = new GlobalBusEmitter() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 9ee8e6fb03..449694a53a 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" const log = Log.create({ service: "bus" }) @@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define( ) type Payload = { + id: string type: D["type"] properties: BusProperties } @@ -28,7 +30,11 @@ type State = { } export interface Interface { - readonly publish: (def: D, properties: BusProperties) => Effect.Effect + readonly publish: ( + def: D, + properties: BusProperties, + options?: { id?: string }, + ) => Effect.Effect readonly subscribe: (def: D) => Stream.Stream> readonly subscribeAll: () => Stream.Stream readonly subscribeCallback: ( @@ -53,6 +59,7 @@ export const layer = Layer.effect( // Publish InstanceDisposed before shutting down so subscribers see it yield* PubSub.publish(wildcard, { type: InstanceDisposed.type, + id: createID(), properties: { directory: ctx.directory }, }) yield* PubSub.shutdown(wildcard) @@ -77,10 +84,10 @@ export const layer = Layer.effect( }) } - function publish(def: D, properties: BusProperties) { + function publish(def: D, properties: BusProperties, options?: { id?: string }) { return Effect.gen(function* () { const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } + const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } log.info("publishing", { type: def.type }) const ps = s.typed.get(def.type) @@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export async function publish(def: D, properties: BusProperties) { - return runPromise((svc) => svc.publish(def, properties)) +export function createID() { + return Identifier.create("evt", "ascending") +} + +export async function publish( + def: D, + properties: BusProperties, + options?: { id?: string }, +) { + return runPromise((svc) => svc.publish(def, properties, options)) } export function subscribe(def: D, callback: (event: Payload) => unknown) { diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 2604e703ea..fa39ecb177 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,17 +1,16 @@ -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "../project/bootstrap" import { Instance } from "../project/instance" +import { InstanceRuntime } from "../project/instance-runtime" +import { WithInstance } from "../project/with-instance" export async function bootstrap(directory: string, cb: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { try { const result = await cb() return result } finally { - await Instance.dispose() + await InstanceRuntime.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 38c28032cd..e0755577b6 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { Account } from "@/account/account" import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd } from "../effect-cmd" import * as Prompt from "../effect/prompt" import open from "open" @@ -172,60 +172,65 @@ const openEffect = Effect.fn("open")(function* () { yield* Prompt.outro("Opened " + url) }) -export const LoginCommand = cmd({ +export const LoginCommand = effectCmd({ command: "login ", describe: false, + instance: false, builder: (yargs) => yargs.positional("url", { describe: "server URL", type: "string", demandOption: true, }), - async handler(args) { + handler: Effect.fn("Cli.account.login")(function* (args) { UI.empty() - await AppRuntime.runPromise(loginEffect(args.url)) - }, + yield* Effect.orDie(loginEffect(args.url)) + }), }) -export const LogoutCommand = cmd({ +export const LogoutCommand = effectCmd({ command: "logout [email]", describe: false, + instance: false, builder: (yargs) => yargs.positional("email", { describe: "account email to log out from", type: "string", }), - async handler(args) { + handler: Effect.fn("Cli.account.logout")(function* (args) { UI.empty() - await AppRuntime.runPromise(logoutEffect(args.email)) - }, + yield* Effect.orDie(logoutEffect(args.email)) + }), }) -export const SwitchCommand = cmd({ +export const SwitchCommand = effectCmd({ command: "switch", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.switch")(function* () { UI.empty() - await AppRuntime.runPromise(switchEffect()) - }, + yield* Effect.orDie(switchEffect()) + }), }) -export const OrgsCommand = cmd({ +export const OrgsCommand = effectCmd({ command: "orgs", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.orgs")(function* () { UI.empty() - await AppRuntime.runPromise(orgsEffect()) - }, + yield* Effect.orDie(orgsEffect()) + }), }) -export const OpenCommand = cmd({ +export const OpenCommand = effectCmd({ command: "open", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.open")(function* () { UI.empty() - await AppRuntime.runPromise(openEffect()) - }, + yield* Effect.orDie(openEffect()) + }), }) export const ConsoleCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 9095984fe3..e24262307c 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,15 +1,16 @@ import * as Log from "@opencode-ai/core/util/log" -import { bootstrap } from "../bootstrap" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" +import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) -export const AcpCommand = cmd({ +export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { @@ -19,52 +20,54 @@ export const AcpCommand = cmd({ default: process.cwd(), }) }, - handler: async (args) => { + handler: Effect.fn("Cli.acp")(function* (args) { process.env.OPENCODE_CLIENT = "acp" - await bootstrap(process.cwd(), async () => { - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) - const sdk = createOpencodeClient({ - baseUrl: `http://${server.hostname}:${server.port}`, - }) - - const input = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - process.stdout.write(chunk, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }, - }) - const output = new ReadableStream({ - start(controller) { - process.stdin.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)) - }) - process.stdin.on("end", () => controller.close()) - process.stdin.on("error", (err) => controller.error(err)) - }, - }) - - const stream = ndJsonStream(input, output) - const agent = await ACP.init({ sdk }) - - new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) - }, stream) - - log.info("setup connection") - process.stdin.resume() - await new Promise((resolve, reject) => { - process.stdin.on("end", resolve) - process.stdin.on("error", reject) - }) + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + headers: ServerAuth.headers(), }) - }, + + const input = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + }, + }) + const output = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (err) => controller.error(err)) + }, + }) + + const stream = ndJsonStream(input, output) + const agent = ACP.init({ sdk }) + + new AgentSideConnection((conn) => { + return agent.create(conn, { sdk }) + }, stream) + + log.info("setup connection") + process.stdin.resume() + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + process.stdin.on("end", () => resolve()) + process.stdin.on("error", reject) + }), + ) + }), }) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a1a440eaa1..2026d82324 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,5 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" @@ -9,9 +8,11 @@ import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { EOL } from "os" import type { Argv } from "yargs" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" type AgentMode = "all" | "primary" | "subagent" @@ -32,7 +33,7 @@ const AVAILABLE_PERMISSIONS = [ "skill", ] -const AgentCreateCommand = cmd({ +const AgentCreateCommand = effectCmd({ command: "create", describe: "create a new agent", builder: (yargs: Argv) => @@ -60,200 +61,191 @@ const AgentCreateCommand = cmd({ alias: ["m"], describe: "model to use in the format of provider/model", }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const cliPath = args.path - const cliDescription = args.description - const cliMode = args.mode as AgentMode | undefined - const perms = args.permissions + handler: Effect.fn("Cli.agent.create")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const agentSvc = yield* Agent.Service + yield* Effect.promise(async () => { + const cliPath = args.path + const cliDescription = args.description + const cliMode = args.mode as AgentMode | undefined + const perms = args.permissions - const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined + const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined - if (!isFullyNonInteractive) { - UI.empty() - prompts.intro("Create agent") - } + if (!isFullyNonInteractive) { + UI.empty() + prompts.intro("Create agent") + } - const project = Instance.project + const project = ctx.project - // Determine scope/path - let targetPath: string - if (cliPath) { - targetPath = path.join(cliPath, "agent") - } else { - let scope: "global" | "project" = "global" - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: "project" as const, - hint: Instance.worktree, - }, - { - label: "Global", - value: "global" as const, - hint: Global.Path.config, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - scope = scopeResult - } - targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), - "agent", - ) - } - - // Get description - let description: string - if (cliDescription) { - description = cliDescription - } else { - const query = await prompts.text({ - message: "Description", - placeholder: "What should this agent do?", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(query)) throw new UI.CancelledError() - description = query - } - - // Generate agent - const spinner = prompts.spinner() - spinner.start("Generating agent configuration...") - const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { - spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) - if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() - }) - spinner.stop(`Agent ${generated.identifier} generated`) - - // Select permissions to allow - let selected: string[] - if (perms !== undefined) { - selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS - } else { - const result = await prompts.multiselect({ - message: "Select permissions to allow (Space to toggle)", - options: AVAILABLE_PERMISSIONS.map((permission) => ({ - label: permission, - value: permission, - })), - initialValues: AVAILABLE_PERMISSIONS, - }) - if (prompts.isCancel(result)) throw new UI.CancelledError() - selected = result - } - - // Get mode - let mode: AgentMode - if (cliMode) { - mode = cliMode - } else { - const modeResult = await prompts.select({ - message: "Agent mode", + // Determine scope/path + let targetPath: string + if (cliPath) { + targetPath = path.join(cliPath, "agent") + } else { + let scope: "global" | "project" = "global" + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "All", - value: "all" as const, - hint: "Can function in both primary and subagent roles", + label: "Current project", + value: "project" as const, + hint: ctx.worktree, }, { - label: "Primary", - value: "primary" as const, - hint: "Acts as a primary/main agent", - }, - { - label: "Subagent", - value: "subagent" as const, - hint: "Can be used as a subagent by other agents", + label: "Global", + value: "global" as const, + hint: Global.Path.config, }, ], - initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - mode = modeResult + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult } + targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent") + } - // Build permissions config — deny anything not explicitly selected. - const permissions: Record = {} - for (const permission of AVAILABLE_PERMISSIONS) { - if (!selected.includes(permission)) { - permissions[permission] = "deny" - } + // Get description + let description: string + if (cliDescription) { + description = cliDescription + } else { + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + description = query + } + + // Generate agent + const spinner = prompts.spinner() + spinner.start("Generating agent configuration...") + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { + spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) + if (isFullyNonInteractive) process.exit(1) + throw new UI.CancelledError() + }) + spinner.stop(`Agent ${generated.identifier} generated`) + + // Select permissions to allow + let selected: string[] + if (perms !== undefined) { + selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS + } else { + const result = await prompts.multiselect({ + message: "Select permissions to allow (Space to toggle)", + options: AVAILABLE_PERMISSIONS.map((permission) => ({ + label: permission, + value: permission, + })), + initialValues: AVAILABLE_PERMISSIONS, + }) + if (prompts.isCancel(result)) throw new UI.CancelledError() + selected = result + } + + // Get mode + let mode: AgentMode + if (cliMode) { + mode = cliMode + } else { + const modeResult = await prompts.select({ + message: "Agent mode", + options: [ + { + label: "All", + value: "all" as const, + hint: "Can function in both primary and subagent roles", + }, + { + label: "Primary", + value: "primary" as const, + hint: "Acts as a primary/main agent", + }, + { + label: "Subagent", + value: "subagent" as const, + hint: "Can be used as a subagent by other agents", + }, + ], + initialValue: "all" as const, + }) + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + mode = modeResult + } + + // Build permissions config — deny anything not explicitly selected. + const permissions: Record = {} + for (const permission of AVAILABLE_PERMISSIONS) { + if (!selected.includes(permission)) { + permissions[permission] = "deny" } + } - // Build frontmatter - const frontmatter: { - description: string - mode: AgentMode - permission?: Record - } = { - description: generated.whenToUse, - mode, - } - if (Object.keys(permissions).length > 0) { - frontmatter.permission = permissions - } + // Build frontmatter + const frontmatter: { + description: string + mode: AgentMode + permission?: Record + } = { + description: generated.whenToUse, + mode, + } + if (Object.keys(permissions).length > 0) { + frontmatter.permission = permissions + } - // Write file - const content = matter.stringify(generated.systemPrompt, frontmatter) - const filePath = path.join(targetPath, `${generated.identifier}.md`) + // Write file + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join(targetPath, `${generated.identifier}.md`) - await fs.mkdir(targetPath, { recursive: true }) - - if (await Filesystem.exists(filePath)) { - if (isFullyNonInteractive) { - console.error(`Error: Agent file already exists: ${filePath}`) - process.exit(1) - } - prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() - } - - await Filesystem.write(filePath, content) + await fs.mkdir(targetPath, { recursive: true }) + if (await Filesystem.exists(filePath)) { if (isFullyNonInteractive) { - console.log(filePath) - } else { - prompts.log.success(`Agent created: ${filePath}`) - prompts.outro("Done") + console.error(`Error: Agent file already exists: ${filePath}`) + process.exit(1) } - }, + prompts.log.error(`Agent file already exists: ${filePath}`) + throw new UI.CancelledError() + } + + await Filesystem.write(filePath, content) + + if (isFullyNonInteractive) { + console.log(filePath) + } else { + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + } }) - }, + }), }) -const AgentListCommand = cmd({ +const AgentListCommand = effectCmd({ command: "list", describe: "list all available agents", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const sortedAgents = agents.sort((a, b) => { - if (a.native !== b.native) { - return a.native ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})` + EOL) - process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) - } - }, + handler: Effect.fn("Cli.agent.list")(function* () { + const agents = yield* Agent.Service.use((svc) => svc.list()) + const sortedAgents = agents.sort((a, b) => { + if (a.native !== b.native) { + return a.native ? -1 : 1 + } + return a.name.localeCompare(b.name) }) - }, + + for (const agent of sortedAgents) { + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) + } + }), }) export const AgentCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index fe6d62d7b6..05af009b88 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[] } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index cff9a7f9cc..1a3f79396c 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,14 +7,13 @@ import { Session } from "@/session/session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "@/tool/registry" -import { Instance } from "../../../project/instance" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import type { InstanceContext } from "@/project/instance" -export const AgentCommand = cmd({ +export const AgentCommand = effectCmd({ command: "agent ", describe: "show agent configuration details", builder: (yargs) => @@ -32,60 +31,60 @@ export const AgentCommand = cmd({ type: "string", description: "Tool params as JSON or a JS object literal", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const agentName = args.name as string - const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName))) - if (!agent) { - process.stderr.write( - `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, - ) - process.exit(1) - } - const availableTools = await getAvailableTools(agent) - const resolvedTools = await resolveTools(agent, availableTools) - const toolID = args.tool as string | undefined - if (toolID) { - const tool = availableTools.find((item) => item.id === toolID) - if (!tool) { - process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) - process.exit(1) - } - if (resolvedTools[toolID] === false) { - process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) - process.exit(1) - } - const params = parseToolParams(args.params as string | undefined) - const ctx = await createToolContext(agent) - const result = await tool.execute(params, ctx) - process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) - return - } - - const output = { - ...agent, - tools: resolvedTools, - } - process.stdout.write(JSON.stringify(output, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.agent")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + return yield* run(args, ctx) + }), }) -async function getAvailableTools(agent: Agent.Info) { - return AppRuntime.runPromise( - Effect.gen(function* () { - const provider = yield* Provider.Service - const registry = yield* ToolRegistry.Service - const model = agent.model ?? (yield* provider.defaultModel()) - return yield* registry.tools({ - ...model, - agent, - }) - }), - ) -} +const run = Effect.fn("Cli.debug.agent.body")(function* ( + args: { name: string; tool?: string; params?: string }, + ctx: InstanceContext, +) { + const agentName = args.name + const agent = yield* Agent.Service.use((svc) => svc.get(agentName)) + if (!agent) { + process.stderr.write( + `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, + ) + return yield* fail("", 1) + } + const availableTools = yield* getAvailableTools(agent) + const resolvedTools = resolveTools(agent, availableTools) + const toolID = args.tool + if (toolID) { + const tool = availableTools.find((item) => item.id === toolID) + if (!tool) { + process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + if (resolvedTools[toolID] === false) { + process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + const params = parseToolParams(args.params) + const toolCtx = yield* createToolContext(agent, ctx) + const result = yield* tool.execute(params, toolCtx) + process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) + return + } -async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { + const output = { + ...agent, + tools: resolvedTools, + } + process.stdout.write(JSON.stringify(output, null, 2) + EOL) +}) + +const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) { + const provider = yield* Provider.Service + const registry = yield* ToolRegistry.Service + const model = agent.model ?? (yield* provider.defaultModel()) + return yield* registry.tools({ ...model, agent }) +}) + +function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) { const disabled = Permission.disabled( availableTools.map((tool) => tool.id), agent.permission, @@ -123,50 +122,38 @@ function parseToolParams(input?: string) { return parsed as Record } -async function createToolContext(agent: Agent.Info) { - const { session, messageID } = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const result = yield* session.create({ title: `Debug tool run (${agent.name})` }) - const messageID = MessageID.ascending() - const model = agent.model - ? agent.model - : yield* Effect.gen(function* () { - const provider = yield* Provider.Service - return yield* provider.defaultModel() - }) - const now = Date.now() - const message: MessageV2.Assistant = { - id: messageID, - sessionID: result.id, - role: "assistant", - time: { - created: now, - }, - parentID: messageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "debug", - agent: agent.name, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } - yield* session.updateMessage(message) - return { session: result, messageID } - }), - ) +const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* ( + agent: Agent.Info, + ctx: InstanceContext, +) { + const sessionSvc = yield* Session.Service + const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` }) + const messageID = MessageID.ascending() + const model = agent.model + ? agent.model + : yield* Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }) + const now = Date.now() + const message: MessageV2.Assistant = { + id: messageID, + sessionID: session.id, + role: "assistant", + time: { created: now }, + parentID: messageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "debug", + agent: agent.name, + path: { + cwd: ctx.directory, + root: ctx.worktree, + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + yield* sessionSvc.updateMessage(message) const ruleset = Permission.merge(agent.permission, session.permission ?? []) @@ -189,4 +176,4 @@ async function createToolContext(agent: Agent.Info) { }) }, } -} +}) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index a80b6a5819..15bd1c1a92 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,17 +1,14 @@ import { EOL } from "os" +import { Effect } from "effect" import { Config } from "@/config/config" -import { AppRuntime } from "@/effect/app-runtime" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" -export const ConfigCommand = cmd({ +export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.config")(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) + process.stdout.write(JSON.stringify(config, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 8e4eaa4e4d..d9bb252ea9 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,11 +1,11 @@ import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -const FileSearchCommand = cmd({ +const FileSearchCommand = effectCmd({ command: "search ", describe: "search files by query", builder: (yargs) => @@ -14,15 +14,13 @@ const FileSearchCommand = cmd({ demandOption: true, description: "Search query", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query }))) - process.stdout.write(results.join(EOL) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.search")(function* (args) { + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) + process.stdout.write(results.join(EOL) + EOL) + }), }) -const FileReadCommand = cmd({ +const FileReadCommand = effectCmd({ command: "read ", describe: "read file contents as JSON", builder: (yargs) => @@ -31,27 +29,23 @@ const FileReadCommand = cmd({ demandOption: true, description: "File path to read", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path))) - process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.read")(function* (args) { + const content = yield* File.Service.use((svc) => svc.read(args.path)) + process.stdout.write(JSON.stringify(content, null, 2) + EOL) + }), }) -const FileStatusCommand = cmd({ +const FileStatusCommand = effectCmd({ command: "status", describe: "show file status information", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status())) - process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.status")(function* () { + const status = yield* File.Service.use((svc) => svc.status()) + process.stdout.write(JSON.stringify(status, null, 2) + EOL) + }), }) -const FileListCommand = cmd({ +const FileListCommand = effectCmd({ command: "list ", describe: "list files in a directory", builder: (yargs) => @@ -60,15 +54,13 @@ const FileListCommand = cmd({ demandOption: true, description: "File path to list", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path))) - process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.file.list")(function* (args) { + const files = yield* File.Service.use((svc) => svc.list(args.path)) + process.stdout.write(JSON.stringify(files, null, 2) + EOL) + }), }) -const FileTreeCommand = cmd({ +const FileTreeCommand = effectCmd({ command: "tree [dir]", describe: "show directory tree", builder: (yargs) => @@ -77,12 +69,10 @@ const FileTreeCommand = cmd({ description: "Directory to tree", default: process.cwd(), }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) - console.log(JSON.stringify(tree, null, 2)) - }) - }, + handler: Effect.fn("Cli.debug.file.tree")(function* (args) { + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) + }), }) export const FileCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 194e66b1f2..6e2643f688 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,11 @@ import { Global } from "@opencode-ai/core/global" -import { bootstrap } from "../../bootstrap" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Flag } from "@opencode-ai/core/flag/flag" +import os from "os" +import { Duration, Effect } from "effect" +import { Config } from "@/config/config" +import { ConfigPlugin } from "@/config/plugin" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" @@ -25,20 +31,49 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) + .command(InfoCommand) .command(PathsCommand) - .command({ - command: "wait", - describe: "wait indefinitely (for debugging)", - async handler() { - await bootstrap(process.cwd(), async () => { - await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) - }) - }, - }) + .command(WaitCommand) .demandCommand(), async handler() {}, }) +const WaitCommand = effectCmd({ + command: "wait", + describe: "wait indefinitely (for debugging)", + handler: Effect.fn("Cli.debug.wait")(function* () { + yield* Effect.sleep(Duration.days(1)) + }), +}) + +const InfoCommand = effectCmd({ + command: "info", + describe: "show debug information", + handler: Effect.fn("Cli.debug.info")(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const termProgram = process.env.TERM_PROGRAM + ? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}` + : undefined + const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ") + + console.log(`opencode version: ${InstallationVersion}`) + console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`) + console.log(`terminal: ${terminal || "unknown"}`) + console.log("plugins:") + if (Flag.OPENCODE_PURE) { + console.log("external plugins disabled (--pure)") + return + } + if (!config.plugin_origins?.length) { + console.log("none") + return + } + for (const plugin of config.plugin_origins) { + console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`) + } + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 6312afcf18..b40b423181 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,7 +1,6 @@ import { LSP } from "@/lsp/lsp" -import { AppRuntime } from "../../../effect/app-runtime" import { Effect } from "effect" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" @@ -14,47 +13,39 @@ export const LSPCommand = cmd({ async handler() {}, }) -const DiagnosticsCommand = cmd({ +const DiagnosticsCommand = effectCmd({ command: "diagnostics ", describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const out = await AppRuntime.runPromise( - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ), - ) - process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) + }), }) -export const SymbolsCommand = cmd({ +export const SymbolsCommand = effectCmd({ command: "symbols ", describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - using _ = Log.Default.time("symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { + using _ = Log.Default.time("symbols") + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) + }), }) -export const DocumentSymbolsCommand = cmd({ +export const DocumentSymbolsCommand = effectCmd({ command: "document-symbols ", describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - using _ = Log.Default.time("document-symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { + using _ = Log.Default.time("document-symbols") + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 9b7e826915..8d1cbd2b1e 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,10 +1,9 @@ import { EOL } from "os" import { Effect, Stream } from "effect" -import { AppRuntime } from "../../../effect/app-runtime" import { Ripgrep } from "../../../file/ripgrep" -import { Instance } from "../../../project/instance" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" export const RipgrepCommand = cmd({ command: "rg", @@ -13,24 +12,22 @@ export const RipgrepCommand = cmd({ async handler() {}, }) -const TreeCommand = cmd({ +const TreeCommand = effectCmd({ command: "tree", describe: "show file tree using ripgrep", builder: (yargs) => yargs.option("limit", { type: "number", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })), - ) - process.stdout.write(tree + EOL) - }) - }, + handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) + process.stdout.write(tree + EOL) + }), }) -const FilesCommand = cmd({ +const FilesCommand = effectCmd({ command: "files", describe: "list files using ripgrep", builder: (yargs) => @@ -47,29 +44,26 @@ const FilesCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise( - Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg - .files({ - cwd: Instance.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - ) - }), + handler: Effect.fn("Cli.debug.rg.files")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, ) - process.stdout.write(files.join(EOL) + EOL) - }) - }, + process.stdout.write(files.join(EOL) + EOL) + }), }) -const SearchCommand = cmd({ +const SearchCommand = effectCmd({ command: "search ", describe: "search file contents using ripgrep", builder: (yargs) => @@ -87,19 +81,19 @@ const SearchCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => - svc.search({ - cwd: Instance.directory, - pattern: args.pattern, - glob: args.glob as string[] | undefined, - limit: args.limit, - }), - ), - ) - process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.rg.search")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const results = yield* Effect.orDie( + Ripgrep.Service.use((svc) => + svc.search({ + cwd: ctx.directory, + pattern: args.pattern, + glob: args.glob as string[] | undefined, + limit: args.limit, + }), + ), + ) + process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index 79179411b6..3b120da3cb 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -1,23 +1,15 @@ import { EOL } from "os" import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" import { Skill } from "../../../skill" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" -export const SkillCommand = cmd({ +export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const skills = await AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) - process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }) - }, + handler: Effect.fn("Cli.debug.skill")(function* () { + const skill = yield* Skill.Service + const skills = yield* skill.all() + process.stdout.write(JSON.stringify(skills, null, 2) + EOL) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 6663398a45..e37e63dc47 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -1,6 +1,6 @@ -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Snapshot } from "../../../snapshot" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" export const SnapshotCommand = cmd({ @@ -10,17 +10,16 @@ export const SnapshotCommand = cmd({ async handler() {}, }) -const TrackCommand = cmd({ +const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", - async handler() { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track()))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.track")(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) + }), }) -const PatchCommand = cmd({ +const PatchCommand = effectCmd({ command: "patch ", describe: "show patch for a snapshot hash", builder: (yargs) => @@ -29,14 +28,13 @@ const PatchCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) + }), }) -const DiffCommand = cmd({ +const DiffCommand = effectCmd({ command: "diff ", describe: "show diff for a snapshot hash", builder: (yargs) => @@ -45,9 +43,8 @@ const DiffCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) + }), }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 62ba20e2ca..bf73ce941e 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,13 +1,11 @@ -import type { Argv } from "yargs" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -220,11 +218,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) } } -export const ExportCommand = cmd({ +export const ExportCommand = effectCmd({ command: "export [sessionID]", describe: "export session data as JSON", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("sessionID", { describe: "session id to export", type: "string", @@ -232,72 +230,62 @@ export const ExportCommand = cmd({ .option("sanitize", { describe: "redact sensitive transcript and file data", type: "boolean", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined - process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) - - if (!sessionID) { - UI.empty() - prompts.intro("Export session", { - output: process.stderr, - }) - - const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list())) - - if (sessions.length === 0) { - prompts.log.error("No sessions found", { - output: process.stderr, - }) - prompts.outro("Done", { - output: process.stderr, - }) - return - } - - sessions.sort((a, b) => b.time.updated - a.time.updated) - - const selectedSession = await prompts.autocomplete({ - message: "Select session to export", - maxItems: 10, - options: sessions.map((session) => ({ - label: session.title, - value: session.id, - hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, - })), - output: process.stderr, - }) - - if (prompts.isCancel(selectedSession)) { - throw new UI.CancelledError() - } - - sessionID = selectedSession - - prompts.outro("Exporting session...", { - output: process.stderr, - }) - } - - try { - const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!))) - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })), - ) - - const exportData = { - info: sessionInfo, - messages, - } - - process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) - process.stdout.write(EOL) - } catch { - UI.error(`Session not found: ${sessionID!}`) - process.exit(1) - } - }) - }, + }), + handler: Effect.fn("Cli.export")(function* (args) { + return yield* run(args) + }), +}) + +const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) { + const svc = yield* Session.Service + let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined + process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) + + if (!sessionID) { + UI.empty() + prompts.intro("Export session", { output: process.stderr }) + + const sessions = yield* svc.list() + + if (sessions.length === 0) { + prompts.log.error("No sessions found", { output: process.stderr }) + prompts.outro("Done", { output: process.stderr }) + return + } + + sessions.sort((a, b) => b.time.updated - a.time.updated) + + const selectedSession = yield* Effect.promise(() => + prompts.autocomplete({ + message: "Select session to export", + maxItems: 10, + options: sessions.map((session) => ({ + label: session.title, + value: session.id, + hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, + })), + output: process.stderr, + }), + ) + + if (prompts.isCancel(selectedSession)) { + return yield* Effect.die(new UI.CancelledError()) + } + + sessionID = selectedSession + + prompts.outro("Exporting session...", { output: process.stderr }) + } + + // Match legacy try/catch — catches both typed failures and defects + // (Session.Service.get throws NotFoundError as a defect, not a typed E). + return yield* Effect.gen(function* () { + const sessionInfo = yield* svc.get(sessionID!) + const messages = yield* svc.messages({ sessionID: sessionInfo.id }) + + const exportData = { info: sessionInfo, messages } + + process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) + process.stdout.write(EOL) + }).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`))) }) diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 768002957d..cb15b484e3 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,22 +1,28 @@ import { Server } from "../../server/server" -import { PublicApi } from "../../server/routes/instance/httpapi/public" import type { CommandModule } from "yargs" -import { OpenApi } from "effect/unstable/httpapi" type Args = { httpapi: boolean + hono: boolean } export const GenerateCommand = { command: "generate", builder: (yargs) => - yargs.option("httpapi", { - type: "boolean", - default: false, - description: "Generate OpenAPI from the experimental Effect HttpApi contract", - }), + yargs + .option("httpapi", { + type: "boolean", + default: false, + description: + "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", + }) + .option("hono", { + type: "boolean", + default: false, + description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", + }), handler: async (args) => { - const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi() + const specs = args.hono ? await Server.openapiHono() : await Server.openapi() for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index b31825fd99..ea5b35ef78 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,9 +18,9 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@/provider/models" -import { Instance } from "@/project/instance" -import { bootstrap } from "../bootstrap" +import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" @@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" -import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" @@ -199,191 +198,194 @@ export const GithubCommand = cmd({ async handler() {}, }) -export const GithubInstallCommand = cmd({ +export const GithubInstallCommand = effectCmd({ command: "install", describe: "install the GitHub agent", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - { - UI.empty() - prompts.intro("Install GitHub agent") - const app = await getAppInfo() - await installGitHubApp() + handler: Effect.fn("Cli.github.install")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + const modelsDev = yield* ModelsDev.Service + const gitSvc = yield* Git.Service + yield* Effect.promise(async () => { + { + UI.empty() + prompts.intro("Install GitHub agent") + const app = await getAppInfo() + await installGitHubApp() - const providers = await ModelsDev.get().then((p) => { - // TODO: add guide for copilot, for now just hide it - delete p["github-copilot"] - return p + const providers = await Effect.runPromise(modelsDev.get()).then((p) => { + // TODO: add guide for copilot, for now just hide it + delete p["github-copilot"] + return p + }) + + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() + + await addWorkflowFiles() + printNextSteps() + + function printNextSteps() { + let step2 + if (provider === "amazon-bedrock") { + step2 = + "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" + } else { + step2 = [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ].join("\n") + } + + prompts.outro( + [ + "Next steps:", + "", + ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, + step2, + "", + " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + "", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", + ].join("\n"), + ) + } + + async function getAppInfo() { + const project = ctx.project + if (project.vcs !== "git") { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + + // Get repo info + const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then( + (x) => x.text().trim(), + ) + const parsed = parseGitHubRemote(info) + if (!parsed) { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() + } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + } + + async function promptProvider() { + const priority: Record = { + opencode: 0, + anthropic: 1, + openai: 2, + google: 3, + } + let provider = await prompts.select({ + message: "Select provider", + maxItems: 8, + options: pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] === 0 ? "recommended" : undefined, + })), + ), }) - const provider = await promptProvider() - const model = await promptModel() - //const key = await promptKey() + if (prompts.isCancel(provider)) throw new UI.CancelledError() - await addWorkflowFiles() - printNextSteps() + return provider + } - function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, - "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") + async function promptModel() { + const providerData = providers[provider]! + + const model = await prompts.select({ + message: "Select model", + maxItems: 8, + options: pipe( + providerData.models, + values(), + sortBy((x) => x.name ?? x.id), + map((x) => ({ + label: x.name ?? x.id, + value: x.id, + })), + ), + }) + + if (prompts.isCancel(model)) throw new UI.CancelledError() + return model + } + + async function installGitHubApp() { + const s = prompts.spinner() + s.start("Installing GitHub app") + + // Get installation + const installation = await getInstallation() + if (installation) return s.stop("GitHub app already installed") + + // Open browser + const url = "https://github.com/apps/opencode-agent" + const command = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"` + + exec(command, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) } + }) - prompts.outro( - [ - "Next steps:", - "", - ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, - "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", - "", - " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", - ].join("\n"), - ) - } - - async function getAppInfo() { - const project = Instance.project - if (project.vcs !== "git") { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - - // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - const parsed = parseGitHubRemote(info) - if (!parsed) { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } - } - - async function promptProvider() { - const priority: Record = { - opencode: 0, - anthropic: 1, - openai: 2, - google: 3, - } - let provider = await prompts.select({ - message: "Select provider", - maxItems: 8, - options: pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] === 0 ? "recommended" : undefined, - })), - ), - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - return provider - } - - async function promptModel() { - const providerData = providers[provider]! - - const model = await prompts.select({ - message: "Select model", - maxItems: 8, - options: pipe( - providerData.models, - values(), - sortBy((x) => x.name ?? x.id), - map((x) => ({ - label: x.name ?? x.id, - value: x.id, - })), - ), - }) - - if (prompts.isCancel(model)) throw new UI.CancelledError() - return model - } - - async function installGitHubApp() { - const s = prompts.spinner() - s.start("Installing GitHub app") - - // Get installation + // Wait for installation + s.message("Waiting for GitHub app to be installed") + const MAX_RETRIES = 120 + let retries = 0 + do { const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") + if (installation) break - // Open browser - const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } - }) - - // Wait for installation - s.message("Waiting for GitHub app to be installed") - const MAX_RETRIES = 120 - let retries = 0 - do { - const installation = await getInstallation() - if (installation) break - - if (retries > MAX_RETRIES) { - s.stop( - `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, - ) - throw new UI.CancelledError() - } - - retries++ - await sleep(1000) - } while (true) // oxlint-disable-line no-constant-condition - - s.stop("Installed GitHub app") - - async function getInstallation() { - return await fetch( - `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + if (retries > MAX_RETRIES) { + s.stop( + `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - .then((res) => res.json()) - .then((data) => data.installation) + throw new UI.CancelledError() } + + retries++ + await sleep(1000) + } while (true) // oxlint-disable-line no-constant-condition + + s.stop("Installed GitHub app") + + async function getInstallation() { + return await fetch( + `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + ) + .then((res) => res.json()) + .then((data) => data.installation) } + } - async function addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function addWorkflowFiles() { + const envStr = + provider === "amazon-bedrock" + ? "" + : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + await Filesystem.write( + path.join(app.root, WORKFLOW_FILE), + `name: opencode on: issue_comment: @@ -414,17 +416,16 @@ jobs: uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, - ) + ) - prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) - } + prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } - }, + } }) - }, + }), }) -export const GithubRunCommand = cmd({ +export const GithubRunCommand = effectCmd({ command: "run", describe: "run the GitHub agent", builder: (yargs) => @@ -437,8 +438,14 @@ export const GithubRunCommand = cmd({ type: "string", describe: "GitHub personal access token (github_pat_********)", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.github.run")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const gitSvc = yield* Git.Service + const sessionSvc = yield* Session.Service + const sessionShare = yield* SessionShare.Service + const sessionPrompt = yield* SessionPrompt.Service + yield* Effect.promise(async () => { const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context @@ -501,21 +508,20 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -552,24 +558,22 @@ export const GithubRunCommand = cmd({ // Setup opencode session const repoData = await fetchRepo() - session = await AppRuntime.runPromise( - Session.Service.use((svc) => - svc.create({ - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }), - ), + session = await Effect.runPromise( + sessionSvc.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }), ) subscribeSessionEvents() shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id))) + await Effect.runPromise(sessionShare.share(session.id)) return session.id.slice(-8) })() console.log("opencode session", session.id) @@ -879,7 +883,7 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], @@ -942,9 +946,9 @@ export const GithubRunCommand = cmd({ async function chat(message: string, files: PromptFiles = []) { console.log("Sending message to opencode...") - return AppRuntime.runPromise( + return Effect.runPromise( Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const prompt = sessionPrompt const result = yield* prompt.prompt({ sessionID: session.id, messageID: MessageID.ascending(), @@ -1645,5 +1649,5 @@ query($owner: String!, $repo: String!, $number: Int!) { }) } }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index d55aba091a..419e81379b 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,17 +1,14 @@ -import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { AppRuntime } from "@/effect/app-runtime" -import { Schema } from "effect" +import { Effect, Schema } from "effect" const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) const decodePart = Schema.decodeUnknownSync(MessageV2.Part) @@ -78,135 +75,143 @@ export function transformShareData(shareData: ShareData[]): { } } -export const ImportCommand = cmd({ +type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } + +export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", - builder: (yargs: Argv) => { - return yargs.positional("file", { + builder: (yargs) => + yargs.positional("file", { describe: "path to JSON file or share URL", type: "string", demandOption: true, + }), + handler: Effect.fn("Cli.import")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + return yield* runImport(args.file, ctx.project.id) + }), +}) + +const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) { + const share = yield* ShareNext.Service + + let exportData: ExportData | undefined + + const isUrl = file.startsWith("http://") || file.startsWith("https://") + + if (isUrl) { + const slug = parseShareUrl(file) + if (!slug) { + const baseUrl = yield* Effect.orDie(share.url()) + process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) + process.stdout.write(EOL) + return + } + + const baseUrl = new URL(file).origin + const req = yield* Effect.orDie(share.request()) + const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} + + const tryFetch = (url: string) => + Effect.tryPromise({ + try: () => fetch(url, { headers }), + catch: (e) => + new CliError({ + message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`, + }), + }) + + const dataPath = req.api.data(slug) + let response = yield* tryFetch(`${baseUrl}${dataPath}`) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`) + } + + if (!response.ok) { + process.stdout.write(`Failed to fetch share data: ${response.statusText}`) + process.stdout.write(EOL) + return + } + + const shareData = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new CliError({ message: "Share data was not valid JSON" }), }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let exportData: - | { - info: SDKSession - messages: Array<{ - info: Message - parts: Part[] - }> - } - | undefined + const transformed = transformShareData(shareData) - const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") + if (!transformed) { + process.stdout.write(`Share not found or empty: ${slug}`) + process.stdout.write(EOL) + return + } - if (isUrl) { - const slug = parseShareUrl(args.file) - if (!slug) { - const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) - process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) - process.stdout.write(EOL) - return - } + exportData = transformed + } else { + exportData = yield* Effect.promise(() => + Filesystem.readJson>(file).catch(() => undefined), + ) + if (!exportData) { + process.stdout.write(`File not found: ${file}`) + process.stdout.write(EOL) + return + } + } - const parsed = new URL(args.file) - const baseUrl = parsed.origin - const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) - const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} + if (!exportData) { + process.stdout.write(`Failed to read session data`) + process.stdout.write(EOL) + return + } - const dataPath = req.api.data(slug) - let response = await fetch(`${baseUrl}${dataPath}`, { - headers, + const info = Schema.decodeUnknownSync(Session.Info)({ + ...exportData.info, + projectID, + }) as Session.Info + const row = Session.toRow(info) + Database.use((db) => + db + .insert(SessionTable) + .values(row) + .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .run(), + ) + + for (const msg of exportData.messages) { + const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + const { id, sessionID: _, ...msgData } = msgInfo + Database.use((db) => + db + .insert(MessageTable) + .values({ + id, + session_id: row.id, + time_created: msgInfo.time?.created ?? Date.now(), + data: msgData, }) + .onConflictDoNothing() + .run(), + ) - if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = await fetch(`${baseUrl}/api/share/${slug}/data`, { - headers, - }) - } - - if (!response.ok) { - process.stdout.write(`Failed to fetch share data: ${response.statusText}`) - process.stdout.write(EOL) - return - } - - const shareData: ShareData[] = await response.json() - const transformed = transformShareData(shareData) - - if (!transformed) { - process.stdout.write(`Share not found or empty: ${slug}`) - process.stdout.write(EOL) - return - } - - exportData = transformed - } else { - exportData = await Filesystem.readJson>(args.file).catch(() => undefined) - if (!exportData) { - process.stdout.write(`File not found: ${args.file}`) - process.stdout.write(EOL) - return - } - } - - if (!exportData) { - process.stdout.write(`Failed to read session data`) - process.stdout.write(EOL) - return - } - - const info = Schema.decodeUnknownSync(Session.Info)({ - ...exportData.info, - projectID: Instance.project.id, - }) as Session.Info - const row = Session.toRow(info) + for (const part of msg.parts) { + const partInfo = decodePart(part) as MessageV2.Part + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db - .insert(SessionTable) - .values(row) - .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .insert(PartTable) + .values({ + id: partId, + message_id: messageID, + session_id: row.id, + data: partData, + }) + .onConflictDoNothing() .run(), ) + } + } - for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info - const { id, sessionID: _, ...msgData } = msgInfo - Database.use((db) => - db - .insert(MessageTable) - .values({ - id, - session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, - }) - .onConflictDoNothing() - .run(), - ) - - for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part - const { id: partId, sessionID: _s, messageID, ...partData } = partInfo - Database.use((db) => - db - .insert(PartTable) - .values({ - id: partId, - message_id: messageID, - session_id: row.id, - data: partData, - }) - .onConflictDoNothing() - .run(), - ) - } - } - - process.stdout.write(`Imported session: ${exportData.info.id}`) - process.stdout.write(EOL) - }) - }, + process.stdout.write(`Imported session: ${exportData.info.id}`) + process.stdout.write(EOL) }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d244549fff..2ae7cece6a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,6 @@ import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" @@ -9,7 +11,7 @@ import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -17,7 +19,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -64,35 +65,31 @@ function oauthServers(config: Config.Info) { ) } -async function listState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const statuses = yield* mcp.status() - const stored = yield* Effect.all( - Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), - { concurrency: "unbounded" }, - ) - return { config, statuses, stored } - }), - ) +function listState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }) } -async function authState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const auth = yield* Effect.all( - Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), - { concurrency: "unbounded" }, - ) - return { config, auth } - }), - ) +function authState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }) } export const McpCommand = cmd({ @@ -109,73 +106,68 @@ export const McpCommand = cmd({ async handler() {}, }) -export const McpListCommand = cmd({ +export const McpListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list MCP servers and their status", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP Servers") + handler: Effect.fn("Cli.mcp.list")(function* () { + UI.empty() + prompts.intro("MCP Servers") - const { config, statuses, stored } = await listState() - const servers = configuredServers(config) + const { config, statuses, stored } = yield* listState() + const servers = configuredServers(config) - if (servers.length === 0) { - prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") - return + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + for (const [name, serverConfig] of servers) { + const status = statuses[name] + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth + const hasStoredTokens = stored[name] + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } - for (const [name, serverConfig] of servers) { - const status = statuses[name] - const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = stored[name] + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } - let statusIcon: string - let statusText: string - let hint = "" - - if (!status) { - statusIcon = "○" - statusText = "not initialized" - } else if (status.status === "connected") { - statusIcon = "✓" - statusText = "connected" - if (hasOAuth && hasStoredTokens) { - hint = " (OAuth)" - } - } else if (status.status === "disabled") { - statusIcon = "○" - statusText = "disabled" - } else if (status.status === "needs_auth") { - statusIcon = "⚠" - statusText = "needs authentication" - } else if (status.status === "needs_client_registration") { - statusIcon = "✗" - statusText = "needs client registration" - hint = "\n " + status.error - } else { - statusIcon = "✗" - statusText = "failed" - hint = "\n " + status.error - } - - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") - prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, - ) - } - - prompts.outro(`${servers.length} server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} server(s)`) + }), }) -export const McpAuthCommand = cmd({ +export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", builder: (yargs) => @@ -185,98 +177,98 @@ export const McpAuthCommand = cmd({ type: "string", }) .command(McpAuthListCommand), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Authentication") + handler: Effect.fn("Cli.mcp.auth")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Authentication") - const { config, auth } = await authState() - const mcpServers = config.mcp ?? {} - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") - prompts.log.info(` + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") + prompts.log.info(` "mcp": { "my-server": { "type": "remote", "url": "https://example.com/mcp" } }`) - prompts.outro("Done") - return + prompts.outro("Done") + return + } + + let serverName = args.name + if (!serverName) { + // Build options with auth status + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, } + }) - let serverName = args.name - if (!serverName) { - // Build options with auth status - const options = servers.map(([name, cfg]) => { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }) + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to authenticate", + options, + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - const selected = await prompts.select({ - message: "Select MCP server to authenticate", - options, - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) + prompts.outro("Done") + return + } - if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) - prompts.outro("Done") - return - } + // Check if already authenticated + const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))) + if (authStatus === "authenticated") { + const confirm = yield* Effect.promise(() => + prompts.confirm({ + message: `${serverName} already has valid credentials. Re-authenticate?`, + }), + ) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } else if (authStatus === "expired") { + prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) + } - // Check if already authenticated - const authStatus = - auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) - if (authStatus === "authenticated") { - const confirm = await prompts.confirm({ - message: `${serverName} already has valid credentials. Re-authenticate?`, - }) - if (prompts.isCancel(confirm) || !confirm) { - prompts.outro("Cancelled") - return - } - } else if (authStatus === "expired") { - prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) - } + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") - const spinner = prompts.spinner() - spinner.start("Starting OAuth flow...") - - // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) - spinner.start("Waiting for authorization...") - } - }) - - try { - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) + // Subscribe to browser open failure events to show URL for manual opening + const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { + if (evt.properties.mcpName === serverName) { + spinner.stop("Could not open browser automatically") + prompts.log.warn("Please open this URL in your browser to authenticate:") + prompts.log.info(evt.properties.url) + spinner.start("Waiting for authorization...") + } + }) + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + Effect.tap((status) => + Effect.sync(() => { if (status.status === "connected") { spinner.stop("Authentication successful!") } else if (status.status === "needs_client_registration") { @@ -300,55 +292,53 @@ export const McpAuthCommand = cmd({ } else { spinner.stop("Unexpected status: " + status.status, 1) } - } catch (error) { + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { spinner.stop("Authentication failed", 1) + const error = Cause.squash(cause) prompts.log.error(error instanceof Error ? error.message : String(error)) - } finally { - unsubscribe() - } + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) - prompts.outro("Done") - }, - }) - }, + prompts.outro("Done") + }), }) -export const McpAuthListCommand = cmd({ +export const McpAuthListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Status") + handler: Effect.fn("Cli.mcp.auth.list")(function* () { + UI.empty() + prompts.intro("MCP OAuth Status") - const { config, auth } = await authState() - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.outro("Done") - return - } + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.outro("Done") + return + } - for (const [name, serverConfig] of servers) { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = serverConfig.url + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = serverConfig.url - prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) - } + prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) + } - prompts.outro(`${servers.length} OAuth-capable server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} OAuth-capable server(s)`) + }), }) -export const McpLogoutCommand = cmd({ +export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", builder: (yargs) => @@ -356,57 +346,54 @@ export const McpLogoutCommand = cmd({ describe: "name of the MCP server", type: "string", }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Logout") + handler: Effect.fn("Cli.mcp.logout")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Logout") - const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) - const serverNames = Object.keys(credentials) + const credentials = yield* McpAuth.Service.use((auth) => auth.all()) + const serverNames = Object.keys(credentials) - if (serverNames.length === 0) { - prompts.log.warn("No MCP OAuth credentials stored") - prompts.outro("Done") - return - } + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } - let serverName = args.name - if (!serverName) { - const selected = await prompts.select({ - message: "Select MCP server to logout", - options: serverNames.map((name) => { - const entry = credentials[name] - const hasTokens = !!entry.tokens - const hasClient = !!entry.clientInfo - let hint = "" - if (hasTokens && hasClient) hint = "tokens + client" - else if (hasTokens) hint = "tokens" - else if (hasClient) hint = "client registration" - return { - label: name, - value: name, - hint, - } - }), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + let serverName = args.name + if (!serverName) { + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!credentials[serverName]) { - prompts.log.error(`No credentials found for: ${serverName}`) - prompts.outro("Done") - return - } + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) - prompts.log.success(`Removed OAuth credentials for ${serverName}`) - prompts.outro("Done") - }, - }) - }, + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }), }) async function resolveConfigPath(baseDir: string, global = false) { @@ -444,171 +431,171 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat return configPath } -export const McpAddCommand = cmd({ +export const McpAddCommand = effectCmd({ command: "add", describe: "add an MCP server", - async handler() { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add MCP server") + handler: Effect.fn("Cli.mcp.add")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("Add MCP server") - const project = Instance.project + const project = ctx.project - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(Instance.worktree), - resolveConfigPath(Global.Path.config, true), - ]) + // Resolve config paths eagerly for hints + const [projectConfigPath, globalConfigPath] = await Promise.all([ + resolveConfigPath(ctx.worktree), + resolveConfigPath(Global.Path.config, true), + ]) - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, - }, - { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult - } - - const name = await prompts.text({ - message: "Enter MCP server name", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(name)) throw new UI.CancelledError() - - const type = await prompts.select({ - message: "Select MCP server type", + // Determine scope + let configPath = globalConfigPath + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "Local", - value: "local", - hint: "Run a local command", + label: "Current project", + value: projectConfigPath, + hint: projectConfigPath, }, { - label: "Remote", - value: "remote", - hint: "Connect to a remote URL", + label: "Global", + value: globalConfigPath, + hint: globalConfigPath, }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + configPath = scopeResult + } - if (type === "local") { - const command = await prompts.text({ - message: "Enter command to run", - placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + const name = await prompts.text({ + message: "Enter MCP server name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(name)) throw new UI.CancelledError() - const mcpConfig: ConfigMCP.Info = { - type: "local", - command: command.split(" "), - } + const type = await prompts.select({ + message: "Select MCP server type", + options: [ + { + label: "Local", + value: "local", + hint: "Run a local command", + }, + { + label: "Remote", + value: "remote", + hint: "Connect to a remote URL", + }, + ], + }) + if (prompts.isCancel(type)) throw new UI.CancelledError() - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) - prompts.outro("MCP server added successfully") - return + if (type === "local") { + const command = await prompts.text({ + message: "Enter command to run", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() + + const mcpConfig: ConfigMCP.Info = { + type: "local", + command: command.split(" "), } - if (type === "remote") { - const url = await prompts.text({ - message: "Enter MCP server URL", - placeholder: "e.g., https://example.com/mcp", - validate: (x) => { - if (!x) return "Required" - if (x.length === 0) return "Required" - const isValid = URL.canParse(x) - return isValid ? undefined : "Invalid URL" - }, - }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return + } - const useOAuth = await prompts.confirm({ - message: "Does this server require OAuth authentication?", + if (type === "remote") { + const url = await prompts.text({ + message: "Enter MCP server URL", + placeholder: "e.g., https://example.com/mcp", + validate: (x) => { + if (!x) return "Required" + if (x.length === 0) return "Required" + const isValid = URL.canParse(x) + return isValid ? undefined : "Invalid URL" + }, + }) + if (prompts.isCancel(url)) throw new UI.CancelledError() + + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, + }) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + let mcpConfig: ConfigMCP.Info + + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - let mcpConfig: ConfigMCP.Info + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() - if (useOAuth) { - const hasClientId = await prompts.confirm({ - message: "Do you have a pre-registered client ID?", + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - if (hasClientId) { - const clientId = await prompts.text({ - message: "Enter client ID", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } - const hasSecret = await prompts.confirm({ - message: "Do you have a client secret?", - initialValue: false, - }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - - let clientSecret: string | undefined - if (hasSecret) { - const secret = await prompts.password({ - message: "Enter client secret", - }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() - clientSecret = secret - } - - mcpConfig = { - type: "remote", - url, - oauth: { - clientId, - ...(clientSecret && { clientSecret }), - }, - } - } else { - mcpConfig = { - type: "remote", - url, - oauth: {}, - } + mcpConfig = { + type: "remote", + url, + oauth: { + clientId, + ...(clientSecret && { clientSecret }), + }, } } else { mcpConfig = { type: "remote", url, + oauth: {}, } } - - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } else { + mcpConfig = { + type: "remote", + url, + } } - prompts.outro("MCP server added successfully") - }, + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } + + prompts.outro("MCP server added successfully") }) - }, + }), }) -export const McpDebugCommand = cmd({ +export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", builder: (yargs) => @@ -617,182 +604,172 @@ export const McpDebugCommand = cmd({ type: "string", demandOption: true, }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Debug") + handler: Effect.fn("Cli.mcp.debug")(function* (args) { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + yield* Effect.promise(async () => { + UI.empty() + prompts.intro("MCP OAuth Debug") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const serverName = args.name + const mcpServers = config.mcp ?? {} + const serverName = args.name - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } + + if (!isMcpRemote(serverConfig)) { + prompts.log.error(`MCP server ${serverName} is not a remote server`) + prompts.outro("Done") + return + } + + if (serverConfig.oauth === false) { + prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) + prompts.outro("Done") + return + } + + prompts.log.info(`Server: ${serverName}`) + prompts.log.info(`URL: ${serverConfig.url}`) + + // Check stored auth status — services already in hand, run inline. + const { authStatus, entry } = await Effect.runPromise( + Effect.all({ + authStatus: mcp.getAuthStatus(serverName), + entry: auth.get(serverName), + }), + ) + prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) + + if (entry?.tokens) { + prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + if (entry.tokens.expiresAt) { + const expiresDate = new Date(entry.tokens.expiresAt * 1000) + const isExpired = entry.tokens.expiresAt < Date.now() / 1000 + prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) } - - if (!isMcpRemote(serverConfig)) { - prompts.log.error(`MCP server ${serverName} is not a remote server`) - prompts.outro("Done") - return + if (entry.tokens.refreshToken) { + prompts.log.info(` Refresh token: present`) } - - if (serverConfig.oauth === false) { - prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) - prompts.outro("Done") - return + } + if (entry?.clientInfo) { + prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) + if (entry.clientInfo.clientSecretExpiresAt) { + const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) + prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) } + } - prompts.log.info(`Server: ${serverName}`) - prompts.log.info(`URL: ${serverConfig.url}`) + const spinner = prompts.spinner() + spinner.start("Testing connection...") - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } - }), - ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) - } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) - } - } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } - } - - const spinner = prompts.spinner() - spinner.start("Testing connection...") - - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), + id: 1, + }), + }) + + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } + + if (response.status === 401) { + prompts.log.warn("Server returned 401 Unauthorized") + + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + + prompts.log.info("Testing OAuth flow (without completing authorization)...") + + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, }) - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } - - if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) - - prompts.log.info("Testing OAuth flow (without completing authorization)...") - - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + prompts.log.info("No client ID - dynamic registration will be attempted") } - } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) - } - } catch { - // Not JSON, ignore - } - } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) } } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } - prompts.outro("Debug complete") - }, + prompts.outro("Debug complete") }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 0b5d352755..183b1816d2 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,19 +1,16 @@ -import type { Argv } from "yargs" -import { Instance } from "../../project/instance" +import { EOL } from "os" +import { Effect } from "effect" import { Provider } from "@/provider/provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "@/provider/models" -import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" -import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" -export const ModelsCommand = cmd({ +export const ModelsCommand = effectCmd({ command: "models [provider]", describe: "list all available models", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("provider", { describe: "provider ID to filter models by", type: "string", @@ -26,63 +23,44 @@ export const ModelsCommand = cmd({ .option("refresh", { describe: "refresh the models cache from models.dev", type: "boolean", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - await ModelsDev.refresh(true) + yield* ModelsDev.Service.use((s) => s.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - await Instance.provide({ - directory: process.cwd(), - async fn() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() + const provider = yield* Provider.Service + const providers = yield* provider.list() - const print = (providerID: ProviderID, verbose?: boolean) => { - const provider = providers[providerID] - const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } + const print = (providerID: ProviderID, verbose?: boolean) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } + } - if (args.provider) { - const providerID = ProviderID.make(args.provider) - const provider = providers[providerID] - if (!provider) { - yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) - return - } + if (args.provider) { + const providerID = ProviderID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + print(providerID, args.verbose) + return + } - yield* Effect.sync(() => print(providerID, args.verbose)) - return - } - - const ids = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) - - yield* Effect.sync(() => { - for (const providerID of ids) { - print(ProviderID.make(providerID), args.verbose) - } - }) - }), - ) - }, + const ids = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) }) - }, + + for (const providerID of ids) print(ProviderID.make(providerID), args.verbose) + }), }) diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 1ac0b071dd..1529e9b71d 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -1,16 +1,16 @@ import { intro, log, outro, spinner } from "@clack/prompts" -import type { Argv } from "yargs" +import { Effect } from "effect" import { ConfigPaths } from "@/config/paths" import { Global } from "@opencode-ai/core/global" import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install" import { resolvePluginTarget } from "../../plugin/shared" -import { Instance } from "../../project/instance" import { errorMessage } from "../../util/error" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" type Spin = { start: (msg: string) => void @@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps } } -export const PluginCommand = cmd({ +export const PluginCommand = effectCmd({ command: "plugin ", aliases: ["plug"], describe: "install plugin and update config", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("module", { type: "string", describe: "npm module name", @@ -196,9 +196,8 @@ export const PluginCommand = cmd({ type: "boolean", default: false, describe: "replace existing plugin version", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.plug")(function* (args) { const mod = String(args.module ?? "").trim() if (!mod) { UI.error("module is required") @@ -214,20 +213,18 @@ export const PluginCommand = cmd({ global: Boolean(args.global), force: Boolean(args.force), }) - let ok = true - await Instance.provide({ - directory: process.cwd(), - fn: async () => { - ok = await run({ - vcs: Instance.project.vcs, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - }) + const ctx = yield* InstanceRef + if (!ctx) return + const ok = yield* Effect.promise(() => + run({ + vcs: ctx.project.vcs, + worktree: ctx.worktree, + directory: ctx.directory, + }), + ) outro("Done") if (!ok) process.exitCode = 1 - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index f392bab4c8..4209722357 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,11 +1,11 @@ +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../effect-cmd" import { Git } from "@/git" -import { Instance } from "@/project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { Process } from "@/util/process" -export const PrCommand = cmd({ +export const PrCommand = effectCmd({ command: "pr ", describe: "fetch and checkout a GitHub PR branch, then run opencode", builder: (yargs) => @@ -14,125 +14,102 @@ export const PrCommand = cmd({ describe: "PR number to checkout", demandOption: true, }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const project = Instance.project - if (project.vcs !== "git") { - UI.error("Could not find git repository. Please run this command from a git repository.") - process.exit(1) + handler: Effect.fn("Cli.pr")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* fail("Could not load instance context") + if (ctx.project.vcs !== "git") { + return yield* fail("Could not find git repository. Please run this command from a git repository.") + } + + const git = yield* Git.Service + const worktree = ctx.worktree + + const prNumber = args.number + const localBranchName = `pr/${prNumber}` + UI.println(`Fetching and checking out PR #${prNumber}...`) + + const checkout = yield* Effect.promise(() => + Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }), + ) + if (checkout.code !== 0) { + return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) + } + + const prInfoResult = yield* Effect.promise(() => + Process.text( + [ + "gh", + "pr", + "view", + `${prNumber}`, + "--json", + "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", + ], + { nothrow: true }, + ), + ) + + let sessionId: string | undefined + + if (prInfoResult.code === 0 && prInfoResult.text.trim()) { + const prInfo = JSON.parse(prInfoResult.text) + + if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { + const forkOwner = prInfo.headRepositoryOwner.login + const forkName = prInfo.headRepository.name + const remoteName = forkOwner + + const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim() + if (!remotes.split("\n").includes(remoteName)) { + yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + cwd: worktree, + }) + UI.println(`Added fork remote: ${remoteName}`) } - const prNumber = args.number - const localBranchName = `pr/${prNumber}` - UI.println(`Fetching and checking out PR #${prNumber}...`) + yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], { + cwd: worktree, + }) + } - // Use gh pr checkout with custom branch name - const result = await Process.run( - ["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], - { - nothrow: true, - }, - ) + if (prInfo?.body) { + const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) + if (sessionMatch) { + const sessionUrl = sessionMatch[0] + UI.println(`Found opencode session: ${sessionUrl}`) + UI.println(`Importing session...`) - if (result.code !== 0) { - UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) - process.exit(1) - } - - // Fetch PR info for fork handling and session link detection - const prInfoResult = await Process.text( - [ - "gh", - "pr", - "view", - `${prNumber}`, - "--json", - "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", - ], - { nothrow: true }, - ) - - let sessionId: string | undefined - - if (prInfoResult.code === 0) { - const prInfoText = prInfoResult.text - if (prInfoText.trim()) { - const prInfo = JSON.parse(prInfoText) - - // Handle fork PRs - if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { - const forkOwner = prInfo.headRepositoryOwner.login - const forkName = prInfo.headRepository.name - const remoteName = forkOwner - - // Check if remote already exists - const remotes = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - if (!remotes.split("\n").includes(remoteName)) { - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { - cwd: Instance.worktree, - }), - ), - ) - UI.println(`Added fork remote: ${remoteName}`) - } - - // Set upstream to the fork so pushes go there - const headRefName = prInfo.headRefName - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { - cwd: Instance.worktree, - }), - ), - ) - } - - // Check for opencode session link in PR body - if (prInfo && prInfo.body) { - const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) - if (sessionMatch) { - const sessionUrl = sessionMatch[0] - UI.println(`Found opencode session: ${sessionUrl}`) - UI.println(`Importing session...`) - - const importResult = await Process.text(["opencode", "import", sessionUrl], { - nothrow: true, - }) - if (importResult.code === 0) { - const importOutput = importResult.text.trim() - // Extract session ID from the output (format: "Imported session: ") - const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/) - if (sessionIdMatch) { - sessionId = sessionIdMatch[1] - UI.println(`Session imported: ${sessionId}`) - } - } - } + const importResult = yield* Effect.promise(() => + Process.text(["opencode", "import", sessionUrl], { nothrow: true }), + ) + if (importResult.code === 0) { + const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) + if (sessionIdMatch) { + sessionId = sessionIdMatch[1] + UI.println(`Session imported: ${sessionId}`) } } } + } + } - UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) - UI.println() - UI.println("Starting opencode...") - UI.println() + UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) + UI.println() + UI.println("Starting opencode...") + UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] - const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], { + const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const code = yield* Effect.promise( + () => + Process.spawn(["opencode", ...opencodeArgs], { stdin: "inherit", stdout: "inherit", stderr: "inherit", cwd: process.cwd(), - }) - const code = await opencodeProcess.exited - if (code !== 0) throw new Error(`opencode exited with code ${code}`) - }, - }) - }, + }).exited, + ) + // Match legacy throw semantics — propagate as a defect so the top-level + // index.ts catch handles it identically (exit 1, "Unexpected error" banner). + if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`)) + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 278522555f..749139e2dc 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,56 +1,69 @@ import { Auth } from "../../auth" -import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" -import * as prompts from "@clack/prompts" +import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import * as Prompt from "../effect/prompt" import { ModelsDev } from "@/provider/models" + import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { Instance } from "../../project/instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" +import { errorMessage } from "@/util/error" import { text } from "node:stream/consumers" -import { Effect } from "effect" +import { Effect, Option } from "effect" type PluginAuth = NonNullable -const put = (key: string, info: Auth.Info) => - AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set(key, info) - }), - ) +const promptValue = (value: Option.Option) => { + if (Option.isNone(value)) return Effect.die(new UI.CancelledError()) + return Effect.succeed(value.value) +} -async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise { - let index = 0 - if (methodName) { +const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) { + const auth = yield* Auth.Service + yield* Effect.orDie(auth.set(key, info)) +}) + +const cliTry = (message: string, fn: () => PromiseLike) => + Effect.tryPromise({ + try: fn, + catch: (error) => new CliError({ message: message + errorMessage(error) }), + }) + +const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( + plugin: { auth: PluginAuth }, + provider: string, + methodName?: string, +) { + const index = yield* Effect.gen(function* () { + if (!methodName) { + if (plugin.auth.methods.length <= 1) return 0 + return yield* promptValue( + yield* Prompt.select({ + message: "Login method", + options: plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index, + })), + }), + ) + } const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase()) if (match === -1) { - prompts.log.error( + return yield* fail( `Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`, ) - process.exit(1) } - index = match - } else if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } + return match + }) const method = plugin.auth.methods[index] - await new Promise((r) => setTimeout(r, 10)) + yield* Effect.sleep("10 millis") const inputs: Record = {} if (method.prompts) { for (const prompt of method.prompts) { @@ -62,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (prompt.condition && !prompt.condition(inputs)) continue if (prompt.type === "select") { - const value = await prompts.select({ + const value = yield* Prompt.select({ message: prompt.message, options: prompt.options, }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value + inputs[prompt.key] = yield* promptValue(value) + continue } + const value = yield* Prompt.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + inputs[prompt.key] = yield* promptValue(value) } } if (method.type === "oauth") { - const authorize = await method.authorize(inputs) + const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs)) if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) + yield* Prompt.log.info("Go to: " + authorize.url) } if (authorize.method === "auto") { if (authorize.instructions) { - prompts.log.info(authorize.instructions) + yield* Prompt.log.info(authorize.instructions) } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() + const spinner = Prompt.spinner() + yield* spinner.start("Waiting for authorization...") + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback()) if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) + yield* spinner.stop("Failed to authorize", 1) } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -110,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - spinner.stop("Login successful") + yield* spinner.stop("Login successful") } } if (authorize.method === "code") { - const code = await prompts.text({ + const code = yield* Prompt.text({ message: "Paste the authorization code here: ", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) + const authorizationCode = yield* promptValue(code) + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -142,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } if (method.type === "api") { - const key = await prompts.password({ + const key = yield* Prompt.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(key)) throw new UI.CancelledError() + const apiKey = yield* promptValue(key) const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} - if (!method.authorize) { - await put(provider, { + const authorizeApi = method.authorize + if (!authorizeApi) { + yield* put(provider, { type: "api", - key, + key: apiKey, ...metadata, }) - prompts.outro("Done") + yield* Prompt.outro("Done") return true } - const result = await method.authorize(inputs) + const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider - await put(saveProvider, { + yield* put(saveProvider, { type: "api", - key: result.key ?? key, + key: result.key ?? apiKey, ...metadata, }) - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } return false -} +}) export function resolvePluginProviders(input: { hooks: Hooks[] @@ -229,30 +241,30 @@ export const ProvidersCommand = cmd({ async handler() {}, }) -export const ProvidersListCommand = cmd({ +export const ProvidersListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list providers and credentials", - async handler(_args) { + // Lists global credentials + provider env vars; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.list")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await ModelsDev.get() + yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = Object.entries(yield* Effect.orDie(authSvc.all())) + const database = yield* modelsDev.get() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } - prompts.outro(`${results.length} credentials`) + yield* Prompt.outro(`${results.length} credentials`) const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -269,18 +281,18 @@ export const ProvidersListCommand = cmd({ if (activeEnvVars.length > 0) { UI.empty() - prompts.intro("Environment") + yield* Prompt.intro("Environment") for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) } - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } - }, + }), }) -export const ProvidersLoginCommand = cmd({ +export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -299,228 +311,202 @@ export const ProvidersLoginCommand = cmd({ describe: "login method label (skips method selection)", type: "string", }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) - prompts.outro("Done") - return - } - await ModelsDev.refresh(true).catch(() => {}) + handler: Effect.fn("Cli.providers.login")(function* (args) { + const authSvc = yield* Auth.Service - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - - const providers = await ModelsDev.get().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - return filtered - }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, - } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), - }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), - ), - ...pluginProviders.map((x) => ({ - label: x.name, - value: x.id, - hint: "plugin", - })), - ] - - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string - } - - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) - if (handled) return - } - - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") - - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } - - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) - } - - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } - - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } - - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } - - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } - - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) - - prompts.outro("Done") - }, - }) - }, -}) - -export const ProvidersLogoutCommand = cmd({ - command: "logout", - describe: "log out from a configured provider", - async handler(_args) { UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") + yield* Prompt.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => + fetch(`${url}/.well-known/opencode`).then((x) => x.json()), + )) as { + auth: { command: string[]; env: string } + } + yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const abort = new AbortController() + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal }) + if (!proc.stdout) { + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") + return + } + const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () => + Promise.all([proc.exited, text(proc.stdout!)]), + ).pipe(Effect.ensuring(Effect.sync(() => abort.abort()))) + if (exit !== 0) { + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") + return + } + yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) + yield* Prompt.log.success("Logged into " + url) + yield* Prompt.outro("Done") return } - const database = await ModelsDev.get() - const selected = await prompts.select({ + + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + const modelsDev = yield* ModelsDev.Service + yield* Effect.ignore(modelsDev.refresh(true)) + + const config = yield* cfgSvc.get() + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const allProviders = yield* modelsDev.get() + const providers: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value + } + const hooks = yield* pluginSvc.list() + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], + })), + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] + + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + return yield* fail(`Unknown provider "${input}"`) + } + provider = match.value + } else { + provider = yield* promptValue( + yield* Prompt.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [...options, { value: "other", label: "Other" }], + }), + ) + } + + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method) + if (handled) return + } + + if (provider === "other") { + provider = (yield* promptValue( + yield* Prompt.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }), + )).replace(/^@ai-sdk\//, "") + + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method) + if (handled) return + } + + yield* Prompt.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } + + if (provider === "amazon-bedrock") { + yield* Prompt.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } + + if (provider === "opencode") { + yield* Prompt.log.info("Create an api key at https://opencode.ai/auth") + } + + if (provider === "vercel") { + yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } + + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + yield* Prompt.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } + + const key = yield* Prompt.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + const apiKey = yield* promptValue(key) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey })) + + yield* Prompt.outro("Done") + }), +}) + +export const ProvidersLogoutCommand = effectCmd({ + command: "logout", + describe: "log out from a configured provider", + // Removes a global auth credential; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.logout")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service + + UI.empty() + const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + yield* Prompt.intro("Remove credential") + if (credentials.length === 0) { + yield* Prompt.log.error("No credentials found") + return + } + const database = yield* modelsDev.get() + const selected = yield* Prompt.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })), }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - prompts.outro("Logout successful") - }, + yield* Effect.orDie(authSvc.remove(yield* promptValue(selected))) + yield* Prompt.outro("Logout successful") + }), }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c94e962038..a05b273e44 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,10 +1,11 @@ import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" -import { bootstrap } from "../bootstrap" +import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -22,10 +23,10 @@ import { WriteTool } from "../../tool/write" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { ShellTool } from "../../tool/shell" +import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" -import { AppRuntime } from "@/effect/app-runtime" type ToolProps = { input: Tool.InferParameters @@ -175,7 +176,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -202,11 +203,17 @@ function normalizePath(input?: string) { return input } -export const RunCommand = cmd({ +export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - builder: (yargs: Argv) => { - return yargs + // --attach connects to a remote server (no local instance needed); the + // default path runs an in-process server and needs the project instance. + instance: (args) => !args.attach, + // For --dir without --attach, load instance for the resolved target dir. + // The handler also chdirs (preserving the legacy order: chdir → file resolution). + directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), + builder: (yargs: Argv) => + yargs .positional("message", { describe: "message to send", type: "string", @@ -269,6 +276,11 @@ export const RunCommand = cmd({ type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }) .option("dir", { type: "string", describe: "directory to run in, path on remote server if attaching", @@ -290,291 +302,314 @@ export const RunCommand = cmd({ type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, - }) - }, - handler: async (args) => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") + }), + handler: Effect.fn("Cli.run")(function* (args) { + const agentSvc = yield* Agent.Service + yield* Effect.promise(async () => { + let message = [...args.message, ...(args["--"] || [])] + .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" ") - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir - try { - process.chdir(args.dir) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] - - for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) process.exit(1) } + })() - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + if (args.file) { + const list = Array.isArray(args.file) ? args.file : [args.file] - files.push({ - type: "file", - url: pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) - } - } - - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - - if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session - - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id - } - - if (baseID) return baseID - - const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id - } - - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) - } - } - - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === "bash") return bash(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) - } - } - - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) - return true - } - return false - } - - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { - const toggles = new Map() - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + for (const filePath of list) { + const resolvedPath = path.resolve(process.cwd(), filePath) + if (!(await Filesystem.exists(resolvedPath))) { + UI.error(`File not found: ${filePath}`) + process.exit(1) } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue + const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue + files.push({ + type: "file", + url: pathToFileURL(resolvedPath).href, + filename: path.basename(resolvedPath), + mime, + }) + } + } + + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + + if (message.trim().length === 0 && !args.command) { + UI.error("You must provide a message or a command") + process.exit(1) + } + + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exit(1) + } + + const rules: Permission.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } + + async function session(sdk: OpencodeClient) { + const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + + if (baseID && args.fork) { + const forked = await sdk.session.fork({ sessionID: baseID }) + return forked.data?.id + } + + if (baseID) return baseID + + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id + } + + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + } + } + + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + try { + if (part.tool === ShellID.ToolID) return shell(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } catch { + return fallback(part) + } + } + + function emit(type: string, data: Record) { + if (args.format === "json") { + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + return true + } + return false + } + + const events = await sdk.event.subscribe() + let error: string | undefined + + async function loop() { + const toggles = new Map() + + for await (const event of events.stream) { + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true + ) { + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) + } + + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) + + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) + } + + if (part.type === "step-start") { + if (emit("step_start", { part })) continue + } + + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } + + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } + UI.empty() + UI.println(text) + UI.empty() + } + + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) + } + } + + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) } if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) + break } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } - - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) } - UI.empty() - UI.println(text) - UI.empty() - } - - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { - UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) - UI.empty() - continue - } - process.stdout.write(line + EOL) - } - } - - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break - } - - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue - - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, - ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) } } } - } - // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + // Validate agent if specified + const agent = await (async () => { + if (!args.agent) return undefined + const name = args.agent - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + // When attaching, validate against the running server instead of local Instance state. + if (args.attach) { + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined + if (!modes) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, + ) + return undefined + } + + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } + + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } + + return name } - const agent = modes.find((a) => a.name === name) - if (!agent) { + const entry = await Effect.runPromise(agentSvc.get(name)) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -582,8 +617,7 @@ export const RunCommand = cmd({ ) return undefined } - - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -591,76 +625,48 @@ export const RunCommand = cmd({ ) return undefined } - return name + })() + + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) } + await share(sdk, sessionID) - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined - } - return name - })() - - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) - - loop().catch((e) => { - console.error(e) - process.exit(1) - }) - - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], + loop().catch((e) => { + console.error(e) + process.exit(1) }) + + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + } else { + const model = args.model ? Provider.parseModel(args.model) : undefined + await sdk.session.prompt({ + sessionID, + agent, + model, + variant: args.variant, + parts: [...files, { type: "text", text: message }], + }) + } } - } - if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) - } + if (args.attach) { + const headers = ServerAuth.headers({ password: args.password, username: args.username }) + const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + return await execute(sdk) + } - await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) return Server.Default().app.fetch(request) @@ -668,5 +674,5 @@ export const RunCommand = cmd({ const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) await execute(sdk) }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 6a0abfbc62..a8a7234d9a 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,23 +1,24 @@ +import { Effect } from "effect" import { Server } from "../../server/server" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { bootstrap } from "../bootstrap" import { Flag } from "@opencode-ai/core/flag/flag" -export const ServeCommand = cmd({ +export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // need for an ambient project InstanceContext at startup. + instance: false, + handler: Effect.fn("Cli.serve")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - - const opts = await bootstrap(process.cwd(), () => resolveNetworkOptions(args)) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 52a3d7204e..08c0df929c 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -1,8 +1,9 @@ import type { Argv } from "yargs" +import { Effect } from "effect" import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { Session } from "@/session/session" import { SessionID } from "../../session/schema" -import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Locale } from "@/util/locale" import { Flag } from "@opencode-ai/core/flag/flag" @@ -11,7 +12,6 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { AppRuntime } from "@/effect/app-runtime" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -47,36 +47,30 @@ export const SessionCommand = cmd({ async handler() {}, }) -export const SessionDeleteCommand = cmd({ +export const SessionDeleteCommand = effectCmd({ command: "delete ", describe: "delete a session", - builder: (yargs: Argv) => { - return yargs.positional("sessionID", { + builder: (yargs) => + yargs.positional("sessionID", { describe: "session ID to delete", type: "string", demandOption: true, - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const sessionID = SessionID.make(args.sessionID) - try { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) - } catch { - UI.error(`Session not found: ${args.sessionID}`) - process.exit(1) - } - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }) - }, + }), + handler: Effect.fn("Cli.session.delete")(function* (args) { + const svc = yield* Session.Service + const sessionID = SessionID.make(args.sessionID) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) + }), }) -export const SessionListCommand = cmd({ +export const SessionListCommand = effectCmd({ command: "list", describe: "list sessions", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("max-count", { alias: "n", describe: "limit to N most recent sessions", @@ -87,28 +81,18 @@ export const SessionListCommand = cmd({ type: "string", choices: ["table", "json"], default: "table", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const sessions = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })), - ) + }), + handler: Effect.fn("Cli.session.list")(function* (args) { + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - if (sessions.length === 0) { - return - } + if (sessions.length === 0) return - let output: string - if (args.format === "json") { - output = formatSessionJSON(sessions) - } else { - output = formatSessionTable(sessions) - } + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) - const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" - if (shouldPaginate) { + if (shouldPaginate) { + yield* Effect.promise(async () => { const proc = Process.spawn(pagerCmd(), { stdin: "pipe", stdout: "inherit", @@ -123,11 +107,11 @@ export const SessionListCommand = cmd({ proc.stdin.write(output) proc.stdin.end() await proc.exited - } else { - console.log(output) - } - }) - }, + }) + } else { + console.log(output) + } + }), }) function formatSessionTable(sessions: Session.Info[]): string { diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 6d00fdf9f4..0124a26932 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,12 +1,10 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" -import { bootstrap } from "../bootstrap" import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" -import { Instance } from "../../project/instance" -import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" interface SessionStats { totalSessions: number @@ -47,11 +45,11 @@ interface SessionStats { medianTokensPerSession: number } -export const StatsCommand = cmd({ +export const StatsCommand = effectCmd({ command: "stats", describe: "show token usage and cost statistics", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("days", { describe: "show stats for the last N days (default: all time)", type: "number", @@ -66,35 +64,32 @@ export const StatsCommand = cmd({ .option("project", { describe: "filter by project (default: all projects, empty string: current project)", type: "string", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const stats = await aggregateSessionStats(args.days, args.project) - - let modelLimit: number | undefined - if (args.models === true) { - modelLimit = Infinity - } else if (typeof args.models === "number") { - modelLimit = args.models - } - - displayStats(stats, args.tools, modelLimit) - }) - }, + }), + handler: Effect.fn("Cli.stats")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) + let modelLimit: number | undefined + if (args.models === true) { + modelLimit = Infinity + } else if (typeof args.models === "number") { + modelLimit = args.models + } + displayStats(stats, args.tools, modelLimit) + }), }) -async function getCurrentProject(): Promise { - return Instance.project -} +const getAllSessions = Effect.sync(() => + Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), +) -async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) -} - -export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { - const sessions = await getAllSessions() +const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( + days?: number, + projectFilter?: string, + currentProject?: Project.Info, +) { + const svc = yield* Session.Service + const sessions = yield* getAllSessions const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { @@ -117,7 +112,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin if (projectFilter !== undefined) { if (projectFilter === "") { - const currentProject = await getCurrentProject() + if (!currentProject) throw new Error("currentProject required when projectFilter is empty string") filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id) } else { filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter) @@ -163,122 +158,111 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin const sessionTotalTokens: number[] = [] - const BATCH_SIZE = 20 - for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { - const batch = filteredSessions.slice(i, i + BATCH_SIZE) + const results = yield* Effect.forEach( + filteredSessions, + (session) => + Effect.gen(function* () { + const messages = yield* svc.messages({ sessionID: session.id }) - const batchPromises = batch.map(async (session) => { - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: session.id })), - ) + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + let sessionModelUsage: Record< + string, + { + messages: number + tokens: { input: number; output: number; cache: { read: number; write: number } } + cost: number + } + > = {} - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } - let sessionToolUsage: Record = {} - let sessionModelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + const modelKey = `${message.info.providerID}/${message.info.modelID}` + if (!sessionModelUsage[modelKey]) { + sessionModelUsage[modelKey] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, + } + } + sessionModelUsage[modelKey].messages++ + sessionModelUsage[modelKey].cost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + + sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 + sessionModelUsage[modelKey].tokens.output += + (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) + sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 + sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 } } - cost: number - } - > = {} - for (const message of messages) { - if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - - const modelKey = `${message.info.providerID}/${message.info.modelID}` - if (!sessionModelUsage[modelKey]) { - sessionModelUsage[modelKey] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 } } - sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 - - if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 - sessionModelUsage[modelKey].tokens.output += - (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) - sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 - sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 - } } - for (const part of message.parts) { - if (part.type === "tool" && part.tool) { - sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 - } + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionTotalTokens: + sessionTokens.input + + sessionTokens.output + + sessionTokens.reasoning + + sessionTokens.cache.read + + sessionTokens.cache.write, + sessionToolUsage, + sessionModelUsage, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, + latestTime: session.time.updated, + } + }), + { concurrency: 20 }, + ) + + for (const result of results) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } + + for (const [model, usage] of Object.entries(result.sessionModelUsage)) { + if (!stats.modelUsage[model]) { + stats.modelUsage[model] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, } } - - return { - messageCount: messages.length, - sessionCost, - sessionTokens, - sessionTotalTokens: - sessionTokens.input + - sessionTokens.output + - sessionTokens.reasoning + - sessionTokens.cache.read + - sessionTokens.cache.write, - sessionToolUsage, - sessionModelUsage, - earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, - latestTime: session.time.updated, - } - }) - - const batchResults = await Promise.all(batchPromises) - - for (const result of batchResults) { - earliestTime = Math.min(earliestTime, result.earliestTime) - latestTime = Math.max(latestTime, result.latestTime) - sessionTotalTokens.push(result.sessionTotalTokens) - - stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost - stats.totalTokens.input += result.sessionTokens.input - stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning - stats.totalTokens.cache.read += result.sessionTokens.cache.read - stats.totalTokens.cache.write += result.sessionTokens.cache.write - - for (const [tool, count] of Object.entries(result.sessionToolUsage)) { - stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count - } - - for (const [model, usage] of Object.entries(result.sessionModelUsage)) { - if (!stats.modelUsage[model]) { - stats.modelUsage[model] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, - } - } - stats.modelUsage[model].messages += usage.messages - stats.modelUsage[model].tokens.input += usage.tokens.input - stats.modelUsage[model].tokens.output += usage.tokens.output - stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read - stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write - stats.modelUsage[model].cost += usage.cost - } + stats.modelUsage[model].messages += usage.messages + stats.modelUsage[model].tokens.input += usage.tokens.input + stats.modelUsage[model].tokens.output += usage.tokens.output + stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read + stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write + stats.modelUsage[model].cost += usage.cost } } @@ -307,7 +291,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin : sessionTotalTokens[mid] return stats -} +}) export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a26b8cfdfe..ea742f6997 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" +import { SyncProviderV2 } from "@tui/context/sync-v2" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" import { useConnected } from "@tui/component/use-connected" @@ -133,6 +134,8 @@ export function tui(input: { } const renderer = await createCliRenderer(rendererConfig(input.config)) + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" await render(() => { @@ -166,27 +169,29 @@ export function tui(input: { > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index cb6b95a56c..5de937fdcc 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { errorMessage } from "@/util/error" import { validateSession } from "./validate-session" +import { ServerAuth } from "@/server/auth" export const AttachCommand = cmd({ command: "attach ", @@ -38,6 +39,11 @@ export const AttachCommand = cmd({ alias: ["p"], type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", }), handler: async (args) => { const unguard = win32InstallCtrlCGuard() @@ -60,12 +66,7 @@ export const AttachCommand = cmd({ return args.dir } })() - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() try { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 04c6b9945c..09d952ef81 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" @@ -10,15 +10,13 @@ import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" - -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +import { WorkspaceLabel } from "./workspace-label" export function DialogSessionList() { const dialog = useDialog() @@ -44,26 +42,39 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => searchResults() ?? sync.data.session) - function createWorkspace() { - dialog.replace(() => ( - - openWorkspaceSession({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - }) - } - /> - )) - } - function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) + const warp = async (selection: WorkspaceSelection) => { + const workspaceID = await (async () => { + if (selection.type === "none") return null + if (selection.type === "existing") return selection.workspaceID + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + const workspace = result?.data + if (!workspace) { + toast.show({ + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + await project.workspace.sync() + return workspace.id + })() + if (workspaceID === undefined) return + await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID, + sessionID: session.id, + done: list, + }) + } dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID, - sessionID: session.id, - done: list, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warp(selection) + }, + }) return false }} /> @@ -124,30 +128,17 @@ export function DialogSessionList() { .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - let workspaceStatus: WorkspaceStatus | null = null - if (x.workspaceID) { - workspaceStatus = project.workspace.status(x.workspaceID) || "error" - } - - let footer = "" + let footer: JSX.Element | string = "" if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (x.workspaceID) { - let desc = "unknown" - if (workspace) { - desc = `${workspace.type}: ${workspace.name}` - } - - footer = ( - <> - {desc}{" "} - - ● - - + footer = workspace ? ( + + ) : ( + ) } } else { @@ -250,15 +241,6 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, - { - keybind: Keybind.parse("ctrl+w")[0], - title: "new workspace", - side: "right", - disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - onTrigger: () => { - createWorkspace() - }, - }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 009bb74d2c..ad40637575 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -1,199 +1,225 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" -import { setTimeout as sleep } from "node:timers/promises" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" -type Adaptor = { +type Adapter = { type: string name: string description: string } -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, - }) -} +export type WorkspaceSelection = + | { + type: "none" + } + | { + type: "new" + workspaceType: string + workspaceName: string + } + | { + type: "existing" + workspaceID: string + workspaceType: string + workspaceName: string + } -export async function openWorkspaceSession(input: { - dialog: ReturnType - route: ReturnType +type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } +type ExistingWorkspaceSelectValue = { workspace: Workspace } + +async function loadWorkspaceAdapters(input: { sdk: ReturnType sync: ReturnType toast: ReturnType - workspaceID: string }) { - const client = scoped(input.sdk, input.sync, input.workspaceID) - - while (true) { - const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - - input.route.navigate({ - type: "session", - sessionID: result.data.id, - }) - input.dialog.clear() - return - } + const dir = input.sync.path.directory || input.sdk.directory + const url = new URL("/experimental/workspace/adapter", input.sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await input.sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (res) return res + input.toast.show({ + message: "Failed to load workspace adapters", + variant: "error", + }) } -export async function restoreWorkspaceSession(input: { +export async function openWorkspaceSelect(input: { + dialog: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { + input.dialog.clear() + const adapters = await loadWorkspaceAdapters(input) + if (!adapters) return + input.dialog.replace(() => ) +} + +export async function warpWorkspaceSession(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType project: ReturnType toast: ReturnType - workspaceID: string + workspaceID: string | null sessionID: string done?: () => void -}) { +}): Promise { const result = await input.sdk.client.experimental.workspace - .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) + .warp({ + id: input.workspaceID ?? undefined, + sessionID: input.sessionID, + }) .catch(() => undefined) if (!result?.data) { input.toast.show({ - message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, + message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) - return + return false } input.project.workspace.set(input.workspaceID) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]) + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) - input.toast.show({ - message: "Session restored into the new workspace", - variant: "success", - }) input.done?.() - if (input.done) return + if (input.done) return true input.dialog.clear() + return true } -export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { +export function DialogWorkspaceSelect(props: { + adapters?: Adapter[] + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const dialog = useDialog() - const sync = useSync() const project = useProject() + const sync = useSync() const sdk = useSDK() const toast = useToast() - const [creating, setCreating] = createSignal() - const [adaptors, setAdaptors] = createSignal() + const [adapters, setAdapters] = createSignal(props.adapters) onMount(() => { dialog.setSize("medium") void (async () => { - const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adaptor", sdk.url) - if (dir) url.searchParams.set("directory", dir) - const res = await sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (!res) { - toast.show({ - message: "Failed to load workspace adaptors", - variant: "error", - }) - return - } - setAdaptors(res) + if (adapters()) return + const res = await loadWorkspaceAdapters({ sdk, sync, toast }) + if (!res) return + setAdapters(res) })() }) - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", + const options = createMemo[]>(() => { + const list = adapters() + if (!list) return [] + const recent = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .flatMap((session) => (session.workspaceID ? [session.workspaceID] : [])) + .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index) + .slice(0, 3) + .flatMap((workspaceID) => { + const workspace = project.workspace.get(workspaceID) + return workspace ? [workspace] : [] + }) + return [ + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "New workspace", + })), + { + title: "None", + value: { type: "none" as const }, + description: "Use the local project", + category: "Choose workspace", + }, + ...recent.map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { + type: "existing" as const, + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, }, - ] - } - const list = adaptors() - if (!list) { - return [ - { - title: "Loading workspaces...", - value: "loading" as const, - description: "Fetching available workspace adaptors", - }, - ] - } - return list.map((item) => ({ - title: item.name, - value: item.type, - description: item.description, - })) + category: "Choose workspace", + })), + { + title: "View all workspaces", + value: { type: "existing-list" as const }, + description: "Choose from all workspaces", + category: "Choose workspace", + }, + ] }) - const create = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => { - toast.show({ - message: "Creating workspace failed", - variant: "error", - }) - return undefined - }) - - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, - variant: "error", - }) - return - } - - await project.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } - + if (!adapters()) return null return ( - + title="Warp" skipFilter={true} + renderFilter={false} options={options()} onSelect={(option) => { - if (option.value === "creating" || option.value === "loading") return - void create(option.value) + if (!option.value) return + if (option.value.type === "none") { + void props.onSelect(option.value) + return + } + if (option.value.type === "new") { + void props.onSelect(option.value) + return + } + if (option.value.type === "existing") { + void props.onSelect(option.value) + return + } + + dialog.replace(() => ) + }} + /> + ) +} + +function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise | void }) { + const project = useProject() + + const options = createMemo[]>(() => + project.workspace + .list() + .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { workspace }, + })), + ) + + return ( + + title="Existing Workspace" + options={options()} + onSelect={(option) => { + void props.onSelect({ + type: "existing", + workspaceID: option.value.workspace.id, + workspaceType: option.value.workspace.type, + workspaceName: option.value.workspace.name, + }) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index bee104a35d..e3e8074cd1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,4 +1,5 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" +import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import * as Sound from "@tui/util/sound" @@ -554,6 +555,7 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState { export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) { const ctx = props.shape ? build(props.shape) : DEFAULT const { theme } = useTheme() + const renderer = useRenderer() const [rings, setRings] = createSignal([]) const [hold, setHold] = createSignal() const [release, setRelease] = createSignal() @@ -684,6 +686,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = }) const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined)) + const useSubpixelBlocks = () => renderer.capabilities?.rgb === true const renderLine = ( line: string, @@ -789,7 +792,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = } // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values - if (char === "█") { + if (char === "█" && useSubpixelBlocks()) { return ( () + const [workspaceSelection, setWorkspaceSelection] = createSignal() + const [workspaceCreating, setWorkspaceCreating] = createSignal(false) + const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) + const [warpNotice, setWarpNotice] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + function selectWorkspace(selection: WorkspaceSelection | undefined) { + setWorkspaceSelection(selection) + } + + function setCreatingWorkspace(creating: boolean) { + setWorkspaceCreating(creating) + } + + function showWarpNotice(name: string) { + setWarpNotice(`Warped to ${name}`) + setTimeout(() => setWarpNotice(undefined), 4000) + } + + async function createWorkspace(selection: Extract) { + setCreatingWorkspace(true) + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + if (result == undefined || result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + message: "Creating workspace failed", + variant: "error", + }) + return + } + + await project.workspace.sync() + const workspace = result.data + selectWorkspace({ + type: "existing", + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, + }) + setCreatingWorkspace(false) + return workspace + } + + async function warpSession(selection: WorkspaceSelection) { + if (!props.sessionID) { + selectWorkspace(selection) + dialog.clear() + if (selection.type === "new") void createWorkspace(selection) + return + } + selectWorkspace(selection) + dialog.clear() + + const workspace = + selection.type === "none" + ? { id: null, name: "local project" } + : selection.type === "existing" + ? { id: selection.workspaceID, name: selection.workspaceName } + : await createWorkspace(selection) + if (!workspace) return + + const warped = await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID: workspace.id, + sessionID: props.sessionID, + }) + if (warped) showWarpNotice(workspace.name) + } + + createEffect(() => { + if (!workspaceCreating()) { + setWorkspaceCreatingDots(3) + return + } + const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) + function promptModelWarning() { toast.show({ variant: "warning", @@ -212,6 +299,7 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { + if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) @@ -488,6 +576,27 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "Warp", + description: "Change the workspace for the session", + value: "workspace.set", + category: "Session", + enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slash: { + name: "warp", + }, + onSelect: (dialog) => { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + }, + }, ] }) @@ -557,17 +666,11 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - const capture = - store.mode === "normal" - ? auto()?.visible - ? (["escape", "navigate", "submit", "tab"] as const) - : (["tab"] as const) - : undefined - input.traits = { - capture, - suspend: !!props.disabled || store.mode === "shell", - status: store.mode === "shell" ? "SHELL" : undefined, - } + input.traits = computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }) }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { @@ -704,6 +807,8 @@ export function Prompt(props: PromptProps) { ]) async function submit() { + setWarpNotice(undefined) + // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. @@ -712,6 +817,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false + if (workspaceCreating()) return false if (autocomplete?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() @@ -734,30 +840,42 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID: nextWorkspaceID, - sessionID: props.sessionID!, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false }} /> )) return false } + const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { - const res = await sdk.client.session.create({ workspace: props.workspaceID }) + const workspace = workspaceSelection() + const workspaceID = iife(() => { + if (!workspace) return undefined + if (workspace.type === "none") return undefined + if (workspace.type === "existing") return workspace.workspaceID + return undefined + }) + + const res = await sdk.client.session.create({ + workspace: props.workspaceID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) if (res.error) { console.log("Creating a session failed:", res.error) @@ -797,7 +915,6 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode - const variant = local.model.variant.current() const editorSelection = editorContext() const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = @@ -1022,6 +1139,29 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) + const workspaceLabel = createMemo< + | { type: "new"; workspaceType: string } + | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } + | undefined + >(() => { + const selected = workspaceSelection() + if (!selected) return + if (selected.type === "none") return + if (props.sessionID && !workspaceCreating()) return + if (selected.type === "new") { + return { + type: "new", + workspaceType: selected.workspaceType, + } + } + return { + type: "existing", + workspaceType: selected.workspaceType, + workspaceName: selected.workspaceName, + status: selected.type === "existing" ? "connected" : undefined, + } + }) + const spinnerDef = createMemo(() => { const agent = local.agent.current() const color = agent ? local.agent.color(agent.name) : theme.border @@ -1278,7 +1418,7 @@ export function Prompt(props: PromptProps) { }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={theme.text} + cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} /> @@ -1348,86 +1488,124 @@ export function Prompt(props: PromptProps) { /> - }> - - - - [⋯]}> - - - - - {(() => { - const retry = createMemo(() => { - const s = status() - if (s.type !== "retry") return - return s - }) - const message = createMemo(() => { - const r = retry() - if (!r) return - if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) - return "gemini is way too hot right now" - if (r.message.length > 80) return r.message.slice(0, 80) + "..." - return r.message - }) - const isTruncated = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.length > 120 - }) - const [seconds, setSeconds] = createSignal(0) - onMount(() => { - const timer = setInterval(() => { - const next = retry()?.next - if (next) setSeconds(Math.round((next - Date.now()) / 1000)) - }, 1000) - - onCleanup(() => { - clearInterval(timer) + + + + + + [⋯]}> + + + + + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) + const message = createMemo(() => { + const r = retry() + if (!r) return + if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) + return "gemini is way too hot right now" + if (r.message.length > 80) return r.message.slice(0, 80) + "..." + return r.message + }) + const isTruncated = createMemo(() => { + const r = retry() + if (!r) return false + return r.message.length > 120 + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + const handleMessageClick = () => { + const r = retry() + if (!r) return + if (isTruncated()) { + void DialogAlert.show(dialog, "Retry Error", r.message) + } } - } - const retryText = () => { - const r = retry() - if (!r) return "" - const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" - const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const duration = formatDuration(seconds()) + const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } - return ( - - - {retryText()} - - - ) - })()} + return ( + + + {retryText()} + + + ) + })()} + + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - - - + + + {(notice) => ( + + {notice()} + + )} + + + {(workspace) => ( + + + + + + {(() => { + const item = workspace() + if (item.type === "new") { + if (workspaceCreating()) + return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}` + return ( + <> + Workspace (new {item.workspaceType}) + + ) + } + return ( + <> + Workspace {item.workspaceName} + + ) + })()} + + + )} + + {props.hint ?? } + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts new file mode 100644 index 0000000000..e47a1aeba5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -0,0 +1,31 @@ +import type { EditorTraits } from "@opentui/core" + +export type PromptMode = "normal" | "shell" + +export interface PromptTraitsInput { + mode: PromptMode + disabled: boolean + autocompleteVisible: boolean +} + +/** + * Compute the textarea editor traits for the prompt. + * + * `traits.suspend` gates the textarea's keybinding actions (backspace, + * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active + * editing mode — only `disabled` should suspend the textarea, otherwise + * users can type in shell mode but cannot delete or move the cursor. + */ +export function computePromptTraits(input: PromptTraitsInput): EditorTraits { + const capture = + input.mode === "normal" + ? input.autocompleteVisible + ? (["escape", "navigate", "submit", "tab"] as const) + : (["tab"] as const) + : undefined + return { + capture, + suspend: input.disabled, + status: input.mode === "shell" ? "SHELL" : undefined, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx new file mode 100644 index 0000000000..efdbf71587 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx @@ -0,0 +1,19 @@ +import { useTheme } from "@tui/context/theme" + +export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + +export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { + const { theme } = useTheme() + const color = () => { + if (props.status === "connected") return theme.success + if (props.status === "error") return theme.error + return theme.textMuted + } + + return ( + <> + {props.icon ? : undefined} + {props.name} ({props.type}) + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index fbedcccc1b..890f736228 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -68,29 +68,73 @@ function normalize(raw: Record) { } } -async function resolvePlugins(config: Info, configFilepath: string) { - if (!config.plugin) return config - for (let i = 0; i < config.plugin.length; i++) { - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) - } - return config -} - -async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins -} - const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { + const afs = yield* AppFileSystem.Service + + const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const plugins = config.plugin + if (!plugins) return config + for (let i = 0; i < plugins.length; i++) { + plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath)) + } + return config + }) + + const load = (text: string, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ) + const data = ConfigParse.jsonc(expanded, configFilepath) + if (!isRecord(data)) return {} as Info + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const validated = ConfigParse.schema(Info, normalize(data), configFilepath) + return yield* resolvePlugins(validated, configFilepath) + }).pipe( + // catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema + // can sync-throw — those become defects, which orElseSucceed wouldn't catch. + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("invalid tui config", { path: configFilepath, cause }) + return {} as Info + }), + ), + ) + + const loadFile = (filepath: string): Effect.Effect => + Effect.gen(function* () { + // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. + // Matches how parse/schema/plugin failures in load() are handled — every + // broken-config path degrades gracefully rather than crashing TUI startup. + const text = yield* afs.readFileStringSafe(filepath).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("failed to read tui config", { path: filepath, cause }) + return undefined + }), + ), + ) + if (!text) return {} as Info + return yield* load(text, filepath) + }) + + const mergeFile = (acc: Acc, file: string) => + Effect.gen(function* () { + const data = yield* loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins + }) + // Every config dir we may read from: global config dir, any `.opencode` // folders between cwd and home, and OPENCODE_CONFIG_DIR. const directories = yield* ConfigPaths.directories(ctx.directory) @@ -104,19 +148,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 2. Explicit OPENCODE_TUI_CONFIG override, if set. if (Flag.OPENCODE_TUI_CONFIG) { const configFile = Flag.OPENCODE_TUI_CONFIG - yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, configFile) log.debug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while @@ -127,7 +171,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: for (const dir of dirs) { if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } } @@ -192,29 +236,3 @@ export async function waitForDependencies() { export async function get() { return runPromise((svc) => svc.get()) } - -async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) -} - -async function load(text: string, configFilepath: string): Promise { - return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }) - .then((expanded) => ConfigParse.jsonc(expanded, configFilepath)) - .then((data) => { - if (!isRecord(data)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - return ConfigParse.schema(Info, normalize(data), configFilepath) - }) - .then((data) => resolvePlugins(data, configFilepath)) - .catch((error) => { - log.warn("invalid tui config", { path: configFilepath, error }) - return {} - }) -} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx new file mode 100644 index 0000000000..d9d23999d2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -0,0 +1,307 @@ +import { useEvent } from "@tui/context/event" +import type { + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, +} from "@opencode-ai/sdk/v2" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +function activeAssistant(messages: SessionMessage[]) { + const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) + if (index < 0) return + const assistant = messages[index] + return assistant?.type === "assistant" ? assistant : undefined +} + +function activeCompaction(messages: SessionMessage[]) { + const index = messages.findIndex((message) => message.type === "compaction") + if (index < 0) return + const compaction = messages[index] + return compaction?.type === "compaction" ? compaction : undefined +} + +function activeShell(messages: SessionMessage[], callID: string) { + const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) + if (index < 0) return + const shell = messages[index] + return shell?.type === "shell" ? shell : undefined +} + +function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) +} + +function latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") +} + +function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + ) +} + +export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ + name: "SyncV2", + init: () => { + const [store, setStore] = createStore<{ + messages: { + [sessionID: string]: SessionMessage[] + } + }>({ + messages: {}, + }) + + const event = useEvent() + const sdk = useSDK() + + function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + setStore( + "messages", + produce((draft) => { + fn((draft[sessionID] ??= [])) + }), + ) + } + + event.subscribe((event) => { + switch (event.type) { + case "session.next.prompted": { + update(event.properties.sessionID, (draft) => { + draft.unshift({ + id: event.id, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timestamp }, + }) + }) + break + } + case "session.next.synthetic": + update(event.properties.sessionID, (draft) => { + draft.unshift({ + id: event.id, + type: "synthetic", + sessionID: event.properties.sessionID, + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.started": + update(event.properties.sessionID, (draft) => { + draft.unshift({ + id: event.id, + type: "shell", + callID: event.properties.callID, + command: event.properties.command, + output: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.ended": + update(event.properties.sessionID, (draft) => { + const match = activeShell(draft, event.properties.callID) + if (!match) return + match.output = event.properties.output + match.time.completed = event.properties.timestamp + }) + break + case "session.next.step.started": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + draft.unshift({ + id: event.id, + type: "assistant", + agent: event.properties.agent, + model: event.properties.model, + content: [], + snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.step.ended": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = event.properties.finish + currentAssistant.cost = event.properties.cost + currentAssistant.tokens = event.properties.tokens + if (event.properties.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + }) + break + case "session.next.step.failed": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = "error" + currentAssistant.error = event.properties.error + }) + break + case "session.next.text.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ type: "text", text: "" }) + }) + break + case "session.next.text.delta": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text += event.properties.delta + }) + break + case "session.next.text.ended": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text = event.properties.text + }) + break + case "session.next.tool.input.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "tool", + id: event.properties.callID, + name: event.properties.name, + time: { created: event.properties.timestamp }, + state: { status: "pending", input: "" }, + }) + }) + break + case "session.next.tool.input.delta": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status === "pending") match.state.input += event.properties.delta + }) + break + case "session.next.tool.input.ended": + break + case "session.next.tool.called": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (!match) return + match.time.ran = event.properties.timestamp + match.provider = event.properties.provider + match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + }) + break + case "session.next.tool.progress": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state.structured = event.properties.structured + match.state.content = [...event.properties.content] + }) + break + case "session.next.tool.success": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "completed", + input: match.state.input, + structured: event.properties.structured, + content: [...event.properties.content], + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.tool.failed": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "error", + error: event.properties.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.reasoning.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "reasoning", + id: event.properties.reasoningID, + text: "", + }) + }) + break + case "session.next.reasoning.delta": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text += event.properties.delta + }) + break + case "session.next.reasoning.ended": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text = event.properties.text + }) + break + case "session.next.retried": + break + case "session.next.compaction.started": + update(event.properties.sessionID, (draft) => { + draft.unshift({ + id: event.id, + type: "compaction", + reason: event.properties.reason, + summary: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.compaction.delta": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (match) match.summary += event.properties.text + }) + break + case "session.next.compaction.ended": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (!match) return + match.summary = event.properties.text + match.include = event.properties.include + }) + break + } + }) + + const result = { + data: store, + session: { + message: { + async sync(sessionID: string) { + const response = await sdk.client.v2.session.messages({ sessionID }) + setStore("messages", sessionID, reconcile(response.data?.items ?? [])) + }, + fromSession(sessionID: string) { + const messages = store.messages[sessionID] + if (!messages) return [] + return messages + }, + }, + }, + } + + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 5c26d461e5..306c03825e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -416,12 +416,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const values = createMemo(() => { const active = store.themes[store.active] - if (active) return resolveTheme(active, store.mode) + if (active) { + return resolveTheme(active, store.mode) + } const saved = kv.get("theme") if (typeof saved === "string") { const theme = store.themes[saved] - if (theme) return resolveTheme(theme, store.mode) + if (theme) { + return resolveTheme(theme, store.mode) + } } return resolveTheme(store.themes.opencode, store.mode) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx new file mode 100644 index 0000000000..2e5cea9804 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -0,0 +1,1138 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { useSyncV2 } from "@tui/context/sync-v2" +import { SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" +import { useTheme } from "@tui/context/theme" +import { useLocal } from "@tui/context/local" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" +import { Locale } from "@/util/locale" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import path from "path" +import stripAnsi from "strip-ansi" +import type { + SessionMessage, + SessionMessageAgentSwitched, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageCompaction, + SessionMessageModelSwitched, + SessionMessageShell, + SessionMessageSynthetic, + SessionMessageUser, + ToolFileContent, + ToolTextContent, +} from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" + +const id = "internal:session-v2-debug" +const route = "session.v2.messages" + +function currentSessionID(api: TuiPluginApi) { + const current = api.route.current + if (current.name !== "session") return + const sessionID = current.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +function View(props: { api: TuiPluginApi; sessionID: string }) { + const sync = useSyncV2() + const dimensions = useTerminalDimensions() + const { theme, syntax, subtleSyntax } = useTheme() + const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) + const renderedMessages = createMemo(() => messages().toReversed()) + const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + const lastUserCreated = (index: number) => + renderedMessages() + .slice(0, index) + .findLast((message) => message.type === "user")?.time.created + + createEffect(() => { + void sync.session.message.sync(props.sessionID) + }) + + useKeyboard((event) => { + if (event.name !== "escape") return + event.preventDefault() + event.stopPropagation() + props.api.route.navigate("session", { sessionID: props.sessionID }) + }) + + return ( + + + + + + + + + + {(message, index) => ( + + + + + + + + + <> + + + + + + + + + + + + + + + + + + )} + + + + + + + ) +} + +function MissingData(props: { label: string; detail: string }) { + const { theme } = useTheme() + return ( + + + MISSING DATA {props.label} + + {props.detail} + + ) +} + +function UserMessage(props: { message: SessionMessageUser; index: number }) { + const { theme } = useTheme() + const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) + return ( + + {props.message.text} + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + + + ) +} + +function ShellMessage(props: { message: SessionMessageShell }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + setExpanded((prev) => !prev) : undefined} + > + + $ {props.message.command} + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + ) +} + +function CompactionMessage(props: { message: SessionMessageCompaction }) { + const { theme, syntax } = useTheme() + return ( + + + {(summary) => ( + + + + )} + + + ) +} + +function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { + const { theme } = useTheme() + const local = useLocal() + return ( + + + + Switched agent to + {Locale.titlecase(props.message.agent)} + + + ) +} + +function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { + const { theme } = useTheme() + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + return ( + + + + Switched model to + {model()} + + + ) +} + +function UnknownMessage(props: { message: SessionMessage }) { + return +} + +function AssistantMessage(props: { + message: SessionMessageAssistant + last: boolean + syntax: SyntaxStyle + subtleSyntax: SyntaxStyle + start?: number +}) { + const { theme } = useTheme() + const local = useLocal() + const duration = createMemo(() => { + if (!props.message.time.completed) return 0 + return props.message.time.completed - (props.start ?? props.message.time.created) + }) + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) + return ( + <> + + {(part) => ( + + + + + + + + + + + + )} + + + + + + + {props.message.error} + + + + + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + + + + ) +} + +function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { + const { theme } = useTheme() + return ( + + + + + + ) +} + +function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) { + const { theme } = useTheme() + const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) + return ( + + + + + + ) +} + +function AssistantTool(props: { part: SessionMessageAssistantTool }) { + const input = createMemo(() => toolInputRecord(props.part.state.input)) + const toolprops = { + get input() { + return input() + }, + get metadata() { + return props.part.provider?.metadata ?? {} + }, + get output() { + return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) + }, + part: props.part, + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +type ToolProps = { + input: Record + metadata: Record + output?: string + part: SessionMessageAssistantTool +} + +function GenericTool(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => props.output?.trim() ?? "") + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const maxLines = 3 + const overflow = createMemo(() => lines().length > maxLines) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, maxLines), "…"].join("\n") + }) + return ( + + {props.part.name} {input(props.input)} + + } + > + setExpanded((prev) => !prev) : undefined} + > + + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + ) +} + +function InlineTool(props: { + icon: string + complete: unknown + pending: string + spinner?: boolean + children: JSX.Element + part: SessionMessageAssistantTool +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const [margin, setMargin] = createSignal(0) + const [hover, setHover] = createSignal(false) + const [showError, setShowError] = createSignal(false) + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const complete = createMemo(() => !!props.complete) + const denied = createMemo(() => { + const message = error() + if (!message) return false + return ( + message.includes("QuestionRejectedError") || + message.includes("rejected permission") || + message.includes("specified a rule") || + message.includes("user dismissed") + ) + }) + const fg = createMemo(() => { + if (error()) return theme.error + if (complete()) return theme.textMuted + return theme.text + }) + const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) + return ( + error() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (!error()) return + if (renderer.getSelection()?.getSelectedText()) return + setShowError((prev) => !prev) + }} + renderBefore={function () { + const el = this as BoxRenderable + const parent = el.parent + if (!parent) return + const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.id.startsWith("text")) setMargin(1) + }} + > + + + + + + + + {props.icon} + + + + + ~ + + + + + + + + + + {props.children} + + + + + {props.pending} + + + + + + + {error()} + + + + + ) +} + +function BlockTool(props: { + title: string + children: JSX.Element + part?: SessionMessageAssistantTool + onClick?: () => void + spinner?: boolean +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + flexShrink={0} + > + + {props.title} + + } + > + {props.title.replace(/^# /, "")} + + {props.children} + + {error()} + + + ) +} + +function Bash(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) + const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) + const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + + + setExpanded((prev) => !prev) : undefined} + > + + $ {command()} + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + + + {command()} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(count) => ( + <> + ({count()} {count() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => + arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), + ) + return ( + <> + + Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} + {input(props.input, ["filePath"])} + + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(matches) => ( + <> + ({matches()} {matches() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} + + ) +} + +function CodeSearch(props: ToolProps) { + return ( + + Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function WebSearch(props: ToolProps) { + return ( + + Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const content = createMemo(() => stringValue(props.input.content) ?? "") + return ( + + + + + + + + + + + + Write {normalizePath(filePath())} + + + + ) +} + +function Edit(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const diff = createMemo(() => stringValue(props.metadata.diff)) + return ( + + + {(diff) => ( + + + 120 ? "split" : "unified"} + filetype={filetype(filePath())} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + + + )} + + + + Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} + +function ApplyPatch(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) + const fileTitle = (file: Record) => { + const type = stringValue(file.type) + const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" + if (type === "delete") return "# Deleted " + relativePath + if (type === "add") return "# Created " + relativePath + if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath + return "← Patched " + relativePath + } + return ( + + 0}> + + {(file) => ( + + + -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} + + } + > + {(patch) => ( + + 120 ? "split" : "unified"} + filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + )} + + + )} + + + + + Patch + + + + ) +} + +function TodoWrite(props: ToolProps) { + const { theme } = useTheme() + const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) + return ( + + 0 && props.part.state.status === "completed"}> + + + + {(todo) => ( + + {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} + + )} + + + + + + + Updating todos... + + + + ) +} + +function Question(props: ToolProps) { + const { theme } = useTheme() + const questions = createMemo(() => + arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), + ) + const answers = createMemo(() => arrayValue(props.metadata.answers)) + return ( + + 0}> + + + + {(question, index) => ( + + {stringValue(question.question)} + {formatAnswer(answers()[index()])} + + )} + + + + + + + Asked {questions().length} question{questions().length === 1 ? "" : "s"} + + + + ) +} + +function Skill(props: ToolProps) { + return ( + + Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" + + ) +} + +function Task(props: ToolProps) { + const content = createMemo(() => { + const description = stringValue(props.input.description) + if (!description) return pendingInput(props.part) + return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` + }) + return ( + + {content()} + + ) +} + +function Diagnostics(props: { diagnostics: unknown; filePath: string }) { + const { theme } = useTheme() + const errors = createMemo(() => { + if (!isRecord(props.diagnostics)) return [] + const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] + return arrayValue(value) + .flatMap((item) => (isRecord(item) ? [item] : [])) + .filter((diagnostic) => diagnostic.severity === 1) + .slice(0, 3) + }) + return ( + + + + {(diagnostic) => Error {stringValue(diagnostic.message)}} + + + + ) +} + +function toolOutput(content?: Array) { + return (content ?? []) + .map((item) => { + if (item.type === "text") return item.text.trim() + return `[file ${item.name ?? item.uri}]` + }) + .filter(Boolean) + .join("\n") +} + +function toolInputRecord(input: string | Record) { + if (typeof input === "string") return {} + return input +} + +function pendingInput(part: SessionMessageAssistantTool) { + if (part.state.status !== "pending") return "" + return part.state.input.trim() +} + +function toolComplete(part: SessionMessageAssistantTool) { + if (part.state.status === "pending") return pendingInput(part) + return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function numberValue(value: unknown) { + return typeof value === "number" ? value : undefined +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function input(input: Record, omit?: string[]) { + const primitives = Object.entries(input).filter(([key, value]) => { + if (omit?.includes(key)) return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + if (primitives.length === 0) return "" + return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` +} + +function normalizePath(input?: string) { + if (!input) return "" + const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) + const relative = path.relative(process.cwd(), absolute) + if (!relative) return "." + if (!relative.startsWith("..")) return relative + return absolute +} + +function filetype(input?: string) { + if (!input) return "none" + const language = LANGUAGE_EXTENSIONS[path.extname(input)] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function todoIcon(status?: string) { + if (status === "completed") return "✓" + if (status === "in_progress") return "~" + if (status === "cancelled") return "✕" + return "☐" +} + +function formatAnswer(answer: unknown) { + if (!Array.isArray(answer)) return "(no answer)" + if (answer.length === 0) return "(no answer)" + return answer.filter((item): item is string => typeof item === "string").join(", ") +} + +const tui: TuiPlugin = async (api) => { + api.route.register([ + { + name: route, + render(input) { + const sessionID = input.params?.sessionID + if (typeof sessionID !== "string") { + return Missing sessionID + } + return + }, + }, + ]) + + api.command.register(() => [ + { + title: "View v2 session messages", + value: route, + category: "Debug", + suggested: api.route.current.name === "session", + enabled: api.route.current.name === "session", + onSelect() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, + }, + ]) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 856ee0ebb1..2b0d859192 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import SessionV2Debug from "../feature-plugins/system/session-v2" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { Flag } from "@opencode-ai/core/flag/flag" export type InternalTuiPlugin = TuiPluginModule & { id: string @@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarFiles, SidebarFooter, PluginManager, + ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), ] diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2ef5333245..73193d142e 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { readPackageThemes, readPluginId, @@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) return true } - const ready = await Instance.provide({ + const ready = await WithInstance.provide({ directory: state.directory, fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), }).catch((error) => { @@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) { } runtime = next try { - await Instance.provide({ + await WithInstance.provide({ directory: cwd, fn: async () => { const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8855338d1d..d43edd2dd5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,8 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { ShellTool } from "@/tool/shell" +import { ShellID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1552,8 +1553,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1784,7 +1785,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 720a05ff7e..e7e4c7cea3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" +import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === ShellID.ToolID) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 7adc4c1db1..0f9214092e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" +import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const project = useProject() @@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) - const workspaceStatus = () => { + const workspace = () => { const workspaceID = session()?.workspaceID - if (!workspaceID) return "error" - return project.workspace.status(workspaceID) ?? "error" - } - const workspaceLabel = () => { - const workspaceID = session()?.workspaceID - if (!workspaceID) return "unknown" - const info = project.workspace.get(workspaceID) - if (!info) return "unknown" - return `${info.type}: ${info.name}` + if (!workspaceID) return + return project.workspace.get(workspaceID) } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {" "} - {workspaceLabel()} + } + > + {(item) => ( + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index a692391519..27904d6133 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -8,7 +8,6 @@ import { UI } from "@/cli/ui" import * as Log from "@opencode-ai/core/util/log" import { errorMessage } from "@/util/error" import { withTimeout } from "@/util/timeout" -import { Instance } from "@/project/instance" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@opencode-ai/sdk/v2" @@ -191,10 +190,7 @@ export const TuiThreadCommand = cmd({ const prompt = await input(args.prompt) const config = await TuiConfig.get() - const network = await Instance.provide({ - directory: cwd, - fn: () => resolveNetworkOptionsNoConfig(args), - }) + const network = await resolveNetworkOptionsNoConfig(args) const external = process.argv.includes("--port") || diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 4d68c44308..ef7d4bd3bb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,6 +23,7 @@ export interface DialogSelectProps { onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean + renderFilter?: boolean keybind?: { keybind?: Keybind.Info title: string @@ -81,7 +82,7 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable const filtered = createMemo(() => { - if (props.skipFilter) return props.options.filter((x) => x.disabled !== true) + if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() const options = pipe( props.options, @@ -250,30 +251,32 @@ export function DialogSelect(props: DialogSelectProps) { esc - - { - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} - ref={(r) => { - input = r - input.traits = { status: "FILTER" } - setTimeout(() => { - if (!input) return - if (input.isDestroyed) return - input.focus() - }, 1) - }} - placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} - /> - + + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + input.traits = { status: "FILTER" } + setTimeout(() => { + if (!input) return + if (input.isDestroyed) return + input.focus() + }, 1) + }} + placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} + /> + + 0} diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index adb7453a72..90ff2b4d4f 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,17 +1,19 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceRuntime } from "@/project/instance-runtime" +import { WithInstance } from "@/project/with-instance" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" -import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { Effect } from "effect" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" ensureProcessMetadata("worker") @@ -48,7 +50,7 @@ let server: Awaited> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } - const auth = getAuthorizationHeader() + const auth = ServerAuth.header() if (auth && !headers["authorization"] && !headers["Authorization"]) { headers["Authorization"] = auth } @@ -75,30 +77,28 @@ export const rpc = { return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { - await Instance.provide({ + await WithInstance.provide({ directory: input.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { await upgrade().catch(() => {}) }, }) }, async reload() { - await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) + await AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + yield* cfg.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) + }), + ) }, async shutdown() { Log.Default.info("worker shutting down") - await Instance.disposeAll() + await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) }, } Rpc.listen(rpc) - -function getAuthorizationHeader(): string | undefined { - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - return `Basic ${btoa(`${username}:${password}`)}` -} diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0b39975e2a..f20381a014 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,11 +1,11 @@ +import { Effect } from "effect" import { Server } from "../../server/server" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import open from "open" import { networkInterfaces } from "os" -import { bootstrap } from "../bootstrap" function getNetworkIPs() { const nets = networkInterfaces() @@ -29,16 +29,19 @@ function getNetworkIPs() { return results } -export const WebCommand = cmd({ +export const WebCommand = effectCmd({ command: "web", builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // ambient project InstanceContext needed at startup. + instance: false, + handler: Effect.fn("Cli.web")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await bootstrap(process.cwd(), () => resolveNetworkOptions(args)) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) UI.empty() @@ -76,7 +79,6 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 0000000000..ada5f8677d --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,103 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" +import { cmd, type WithDoubleDash } from "./cmd/cmd" + +/** + * User-visible command failure. Throw via `fail("...")` from an effectCmd handler + * to surface a printed message + non-zero exit. Recognised by the global error + * formatter in `src/cli/error.ts` (FormatError), so the existing top-level + * catch + cleanup in `src/index.ts` runs normally. + */ +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** + * Whether the command needs a project InstanceContext. Defaults to true. + * + * `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})` + * so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via + * `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy + * `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin + * init + LSP/File/etc forks) eagerly. + * + * `false`: skip the instance entirely. Saves the InstanceBootstrap work and + * suppresses the `server.instance.disposed` IPC event. The handler runs + * directly under AppRuntime — it can yield any `AppServices` but must not + * yield `InstanceRef` (it'd be undefined, causing a defect). + * + * Function form: `(args) => boolean` decides per-invocation. Useful for + * commands like `run --attach ` where one flag flips between local + * (needs instance) and remote (doesn't). + * + * Use `false` for commands that don't read project state (e.g. `models`, + * `serve`, `web`, `account`, `db`, `upgrade`). + */ + instance?: boolean | ((args: Args) => boolean) + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: WithDoubleDash) => Effect.Effect +} + +/** + * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is + * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. + * + * The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded + * InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`) + * on every Exit — success, typed failure, defect, or interruption. Matches the + * legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate. + * + * Errors propagate to the existing top-level handler in `src/index.ts`; use + * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * + * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, + * which adds a named tracing span per CLI invocation. Once all commands use + * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's + * `Command.make(...)` won't touch any handler bodies. + */ +export const effectCmd = (opts: EffectCmdOpts) => + cmd<{}, Args>({ + command: opts.command, + aliases: opts.aliases, + describe: opts.describe, + builder: opts.builder as never, + async handler(rawArgs) { + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as WithDoubleDash + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { + await AppRuntime.runPromise(opts.handler(args)) + return + } + const directory = opts.directory?.(args) ?? process.cwd() + // Two-phase: load ctx, then run body inside Instance.current ALS. + // Effect's InstanceRef is provided via fiber context, but that context is + // lost across `await` inside `Effect.promise(async () => ...)` callbacks + // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` + // there, attach() falls back to Instance.current ALS, which Node preserves + // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), + ) + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } + }, + }) diff --git a/packages/opencode/src/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts index 7f9cd8cfe6..2713f1a5b8 100644 --- a/packages/opencode/src/cli/effect/prompt.ts +++ b/packages/opencode/src/cli/effect/prompt.ts @@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg)) export const log = { info: (msg: string) => Effect.sync(() => prompts.log.info(msg)), + error: (msg: string) => Effect.sync(() => prompts.log.error(msg)), + warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)), + success: (msg: string) => Effect.sync(() => prompts.log.success(msg)), +} + +const optional = (result: Value | symbol) => { + if (prompts.isCancel(result)) return Option.none() + return Option.some(result) } export const select = (opts: Parameters>[0]) => - Effect.tryPromise(() => prompts.select(opts)).pipe( - Effect.map((result) => { - if (prompts.isCancel(result)) return Option.none() - return Option.some(result) - }), - ) + Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result))) + +export const autocomplete = (opts: Parameters>[0]) => + Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result))) + +export const text = (opts: Parameters[0]) => + Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result))) + +export const password = (opts: Parameters[0]) => + Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result))) export const spinner = () => { const s = prompts.spinner() diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index adf52f5683..628aa95696 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean { } export function FormatError(input: unknown) { + // CliError: domain failure surfaced from an effectCmd handler via fail("...") + if (isTaggedError(input, "CliError")) { + const data = input as ErrorLike & { exitCode?: number } + if (data.exitCode != null) process.exitCode = data.exitCode + return data.message ?? "" + } + // MCPFailed: { name: string } if (NamedError.hasName(input, "MCPFailed")) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 44841fe6fc..3a933f81e9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -11,11 +11,9 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" -import { Instance, type InstanceContext } from "../project/instance" +import { type InstanceContext } from "../project/instance" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" -import { GlobalBus } from "@/bus/global" -import { Event } from "../server/event" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" @@ -23,7 +21,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { InstanceRef } from "@/effect/instance-ref" +import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -194,8 +192,14 @@ export const Info = Schema.Struct({ ]), ), ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), - formatter: Schema.optional(ConfigFormatter.Info), - lsp: Schema.optional(ConfigLSP.Info), + formatter: Schema.optional(ConfigFormatter.Info).annotate({ + description: + "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), + lsp: Schema.optional(ConfigLSP.Info).annotate({ + description: + "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include", }), @@ -288,9 +292,9 @@ export interface Interface { readonly get: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect - readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect - readonly updateGlobal: (config: Info) => Effect.Effect - readonly invalidate: (wait?: boolean) => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -351,15 +355,7 @@ export const layer = Layer.effect( const env = yield* Env.Service const npmSvc = yield* Npm.Service - const readConfigFile = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => Effect.succeed(undefined), - ), - Effect.orDie, - ) - }) + const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) const loadConfig = Effect.fnUntraced(function* ( text: string, @@ -459,7 +455,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + if (containsPath(source, ctx)) return "local" return "global" }) @@ -729,31 +725,17 @@ export const layer = Layer.effect( ) }) - const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) { + const update = Effect.fn("Config.update")(function* (config: Info) { const dir = yield* InstanceState.directory const file = path.join(dir, "config.json") const existing = yield* loadFile(file) yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) - if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose()) }) - const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { + const invalidate = Effect.fn("Config.invalidate")(function* () { yield* invalidateGlobal - const task = Instance.disposeAll() - .catch(() => undefined) - .finally(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }), - ) - if (wait) yield* Effect.promise(() => task) - else void task }) const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { @@ -777,9 +759,8 @@ export const layer = Layer.effect( if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } - // Only tear down running instances if the config actually changed. if (changed) yield* invalidate() - return next + return { info: next, changed } }) return Service.of({ diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 90f49ee799..82fca570f4 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -1,11 +1,9 @@ export * as ConfigPaths from "./paths" import path from "path" -import { Filesystem } from "@/util/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { unique } from "remeda" -import { JsonError } from "./error" import * as Effect from "effect/Effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -45,11 +43,3 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } - -/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ -export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return - throw new JsonError({ path: filepath }, { cause: err }) - }) -} diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts new file mode 100644 index 0000000000..963e2a2ed5 --- /dev/null +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -0,0 +1,45 @@ +import type { ProjectID } from "@/project/schema" +import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" +import { WorktreeAdapter } from "./worktree" + +const BUILTIN: Record = { + worktree: WorktreeAdapter, +} + +const state = new Map>() + +export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter { + const custom = state.get(projectID)?.get(type) + if (custom) return custom + + const builtin = BUILTIN[type] + if (builtin) return builtin + + throw new Error(`Unknown workspace adapter: ${type}`) +} + +export async function listAdapters(projectID: ProjectID): Promise { + const builtin = await Promise.all( + Object.entries(BUILTIN).map(async ([type, adapter]) => { + return { + type, + name: adapter.name, + description: adapter.description, + } + }), + ) + const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ + type, + name: adapter.name, + description: adapter.description, + })) + return [...builtin, ...custom] +} + +// Plugins can be loaded per-project so we need to scope them. If you +// want to install a global one pass `ProjectID.global` +export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) { + const adapters = state.get(projectID) ?? new Map() + adapters.set(type, adapter) + state.set(projectID, adapters) +} diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts similarity index 92% rename from packages/opencode/src/control-plane/adaptors/worktree.ts rename to packages/opencode/src/control-plane/adapters/worktree.ts index de9618d302..af8f5d8d43 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" +import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, @@ -13,7 +13,7 @@ async function loadWorktree() { return { AppRuntime, Worktree } } -export const WorktreeAdaptor: WorkspaceAdaptor = { +export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", async configure(info) { diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts deleted file mode 100644 index c91f534b5a..0000000000 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ProjectID } from "@/project/schema" -import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types" -import { WorktreeAdaptor } from "./worktree" - -const BUILTIN: Record = { - worktree: WorktreeAdaptor, -} - -const state = new Map>() - -export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor { - const custom = state.get(projectID)?.get(type) - if (custom) return custom - - const builtin = BUILTIN[type] - if (builtin) return builtin - - throw new Error(`Unknown workspace adaptor: ${type}`) -} - -export async function listAdaptors(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, adaptor]) => { - return { - type, - name: adaptor.name, - description: adaptor.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({ - type, - name: adaptor.name, - description: adaptor.description, - })) - return [...builtin, ...custom] -} - -// Plugins can be loaded per-project so we need to scope them. If you -// want to install a global one pass `ProjectID.global` -export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { - const adaptors = state.get(projectID) ?? new Map() - adaptors.set(type, adaptor) - state.set(projectID, adaptors) -} diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index af16c04902..7f3aad7ed1 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> -export const WorkspaceAdaptorEntry = Schema.Struct({ +export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type WorkspaceAdaptorEntry = Schema.Schema.Type +export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = | { @@ -35,7 +35,7 @@ export type Target = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7f9d078bb7..fe651fe3e3 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,10 +1,11 @@ -import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" +import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -16,15 +17,17 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdaptor } from "./adaptors" +import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" +import { EffectBridge } from "@/effect/bridge" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" @@ -37,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({ }) export type ConnectionStatus = Schema.Schema.Type -const Restore = Schema.Struct({ - workspaceID: WorkspaceID, - sessionID: SessionID, - total: NonNegativeInt, - step: NonNegativeInt, -}) - export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -57,7 +53,6 @@ export const Event = { message: Schema.String, }), ), - Restore: BusEvent.define("workspace.restore", Restore), Status: BusEvent.define("workspace.status", ConnectionStatus), } @@ -83,15 +78,15 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectID, - extra: Info.fields.extra, + extra: Schema.optional(Info.fields.extra), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const SessionRestoreInput = Schema.Struct({ - workspaceID: WorkspaceID, +export const SessionWarpInput = Schema.Struct({ + workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) -export type SessionRestoreInput = Schema.Schema.Type +export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { message: Schema.String, @@ -115,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( - "WorkspaceSessionRestoreHttpError", +export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionWarpHttpError", { message: Schema.String, workspaceID: WorkspaceID, @@ -137,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass( }) {} type CreateError = Auth.AuthError -type SessionRestoreError = +type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError - | SessionRestoreHttpError + | SessionWarpHttpError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError> + readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect @@ -168,6 +163,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const auth = yield* Auth.Service const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service const connections = new Map() @@ -335,8 +331,8 @@ export const layer = Layer.effect( }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") return @@ -419,8 +415,8 @@ export const layer = Layer.effect( const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -458,9 +454,9 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) - const adaptor = getAdaptor(input.projectID, input.type) - const config = yield* Effect.promise(() => - Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })), + const adapter = getAdapter(input.projectID, input.type) + const config = yield* EffectBridge.fromPromise(() => + adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), ) const info: Info = { @@ -496,7 +492,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* Effect.promise(() => adaptor.create(config, env)) + yield* EffectBridge.fromPromise(() => adapter.create(config, env)) yield* Effect.all( [ waitEvent({ @@ -517,29 +513,93 @@ export const layer = Layer.effect( return info }) - const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { return yield* Effect.gen(function* () { - log.info("session restore requested", { + log.info("session warp requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, }) - const space = yield* get(input.workspaceID) + const current = yield* db((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) { + const adapter = getAdapter(previous.projectID, previous.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + + if (target.type === "remote") { + yield* syncHistory(previous, target.url, target.headers).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("session warp final source sync failed", { + workspaceID: previous.id, + sessionID: input.sessionID, + error: errorData(error), + }) + }), + ), + ) + } else { + yield* prompt.cancel(input.sessionID) + } + + // "claim" this session so any future events coming from + // the old workspace are ignored + SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + } + } + + if (input.workspaceID === null) { + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }), + ) + + log.info("session warp complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + target: "local", + }) + return + } + + const workspaceID = input.workspaceID + const space = yield* get(workspaceID) if (!space) return yield* new WorkspaceNotFoundError({ - message: `Workspace not found: ${input.workspaceID}`, - workspaceID: input.workspaceID, + message: `Workspace not found: ${workspaceID}`, + workspaceID, }) - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { + if (target.type === "local") { + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + log.info("session warp complete", { workspaceID: input.workspaceID, - }, - }) + sessionID: input.sessionID, + target: target.directory, + }) + return + } const rows = yield* db((db) => db @@ -561,130 +621,95 @@ export const layer = Layer.effect( sessionID: input.sessionID, }) - const size = 10 - // TODO: look into using effect APIs to process this in chunks - const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => - rows.slice(i * size, (i + 1) * size), - ) - const total = sets.length + const batches = Iterable.chunksOf(rows, 10) + const total = Iterable.size(batches) - log.info("session restore prepared", { + log.info("session warp prepared", { workspaceID: input.workspaceID, sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + target: String(route(target.url, "/sync/replay")), events: rows.length, batches: total, first: rows[0]?.seq, last: rows.at(-1)?.seq, }) - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, - }), - ) - - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - }) - - if (target.type === "local") { - yield* sync.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const res = yield* http.execute( - HttpClientRequest.post(url, { - headers: new Headers(target.headers), - body: HttpBody.jsonUnsafe({ - directory: space.directory ?? "", - events, + yield* Effect.forEach( + batches, + (events, i) => + Effect.gen(function* () { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), }), - }), - ) + ) - if (res.status < 200 || res.status >= 300) { - const body = yield* res.text - log.error("session restore batch failed", { + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + + log.info("session warp batch posted", { workspaceID: input.workspaceID, sessionID: input.sessionID, step: i + 1, total, - status: res.status, - body, + status: response.status, }) - return yield* new SessionRestoreHttpError({ - message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: res.status, - body, - }) - } - - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) - } - - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, }), - ) + { discard: true }, + ) + + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/steal"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp steal failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) } - log.info("session restore complete", { + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, batches: total, }) - - return { total } }).pipe( Effect.tapError((err) => Effect.sync(() => - log.error("session restore failed", { + log.error("session warp failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, error: errorData(err), @@ -724,14 +749,14 @@ export const layer = Layer.effect( yield* stopSync(id) const info = fromRow(row) - yield* Effect.catch( + yield* Effect.catchCause( Effect.gen(function* () { - const adaptor = getAdaptor(info.projectID, row.type) - yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info))) + const adapter = getAdapter(info.projectID, row.type) + yield* EffectBridge.fromPromise(() => adapter.remove(info)) }), () => Effect.sync(() => { - log.error("adaptor not available when removing workspace", { type: row.type }) + log.error("adapter not available when removing workspace", { type: row.type }) }), ) @@ -813,7 +838,7 @@ export const layer = Layer.effect( return Service.of({ create, - sessionRestore, + sessionWarp, list, get, remove, @@ -829,6 +854,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f4c9a8f653..4c949cfa57 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" @@ -39,11 +40,13 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" +import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Installation } from "@/installation" import * as Effect from "effect/Effect" import { ShareNext } from "@/share/share-next" @@ -83,6 +86,7 @@ export const AppLayer = Layer.mergeAll( Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, + ModelsDev.defaultLayer, Provider.defaultLayer, ProviderAuth.defaultLayer, Agent.defaultLayer, @@ -111,16 +115,20 @@ export const AppLayer = Layer.mergeAll( Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, -).pipe(Layer.provideMerge(Observability.layer)) +).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick + +/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */ +export type AppServices = ManagedRuntime.ManagedRuntime.Services const wrap = (effect: Parameters[0]) => attach(effect as never) as never export const AppRuntime: Runtime = { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 3c310129f1..16d8f93669 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -21,6 +21,25 @@ function restore(instance: InstanceContext | undefined, workspace: WorkspaceI return fn() } +/** + * Bridge from Effect into a Promise-returning JS callback while installing + * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for + * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do + * not propagate across async/await boundaries inside `Effect.promise(() => + * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, + * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses + * into JS that may itself spawn new Effect runtimes (workspace adapters, + * legacy plugins, etc.). + * + * Mirrors `Effect.promise` but restores legacy ALS first. + */ +export const fromPromise = (fn: () => Promise | T): Effect.Effect => + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspace = yield* WorkspaceRef + return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + }) + export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 28f1068c36..1f3802e80c 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util/local-context" @@ -24,15 +24,20 @@ export function attachWith(effect: Effect.Effect, refs: Refs): } export function attach(effect: Effect.Effect): Effect.Effect { - try { - return attachWith(effect, { - instance: Instance.current, - workspace: WorkspaceContext.workspaceID, - }) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - return effect + const workspace = WorkspaceContext.workspaceID + const instance = (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) + const fiber = Fiber.getCurrent() + return attachWith(effect, { + instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), + }) } export function makeRuntime(service: Context.Service, layer: Layer.Layer) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4a474881cb..4dd6a3ae7a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort" import ignore from "ignore" import path from "path" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -507,7 +507,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full, ctx)) { + if (!containsPath(full, ctx)) { throw new Error("Access denied: path escapes project directory") } @@ -587,7 +587,7 @@ export const layer = Layer.effect( } const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory - if (!Instance.containsPath(resolved, ctx)) { + if (!containsPath(resolved, ctx)) { throw new Error("Access denied: path escapes project directory") } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b68c3a3356..146d7b4d07 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -123,7 +123,9 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) + yield* Effect.forkScoped( + subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]), + ) } if (ctx.project.vcs === "git") { @@ -135,7 +137,7 @@ export const layer = Layer.effect( const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) - yield* subscribe(vcsDir, ignore) + yield* Effect.forkScoped(subscribe(vcsDir, ignore)) } } }, diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 16a8624474..fff1d70b2a 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -24,6 +24,7 @@ const fail = (err: unknown) => text: () => "", stdout: Buffer.alloc(0), stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + truncated: false, }) satisfies Result export type Kind = "added" | "deleted" | "modified" @@ -45,16 +46,28 @@ export type Stat = { readonly deletions: number } +export type Patch = { + readonly text: string + readonly truncated: boolean +} + +export interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + export interface Result { readonly exitCode: number readonly text: () => string readonly stdout: Buffer readonly stderr: Buffer + readonly truncated: boolean } export interface Options { readonly cwd: string readonly env?: Record + readonly maxOutputBytes?: number } export interface Interface { @@ -68,6 +81,10 @@ export interface Interface { readonly status: (cwd: string) => Effect.Effect readonly diff: (cwd: string, ref: string) => Effect.Effect readonly stats: (cwd: string, ref: string) => Effect.Effect + readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly statUntracked: (cwd: string, file: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -96,15 +113,31 @@ export const layer = Layer.effect( stderr: "pipe", }) const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) + const collect = (stream: typeof handle.stdout) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (opts.maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + + const remaining = opts.maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 }) return { exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), + text: () => stdout.buffer.toString("utf8"), + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated || stderr.truncated, } satisfies Result }, Effect.scoped, @@ -240,6 +273,61 @@ export const layer = Layer.effect( }) }) + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("Git.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + if (result.truncated) return + const parts = result.text().split("\t") + if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + return Service.of({ run, branch, @@ -251,6 +339,10 @@ export const layer = Layer.effect( status, diff, stats, + patch, + patchAll, + patchUntracked, + statUntracked, }) }), ) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 46c210fa5d..6d9a6447a0 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,7 @@ const prefixes = { tool: "tool", workspace: "wrk", entry: "ent", + account: "act", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5fcff772ec..5110eccbf8 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -12,7 +12,7 @@ import { Process } from "@/util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" @@ -221,12 +221,7 @@ export const layer = Layer.effect( const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context - if ( - !AppFileSystem.contains(ctx.directory, file) && - (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file)) - ) { - return [] as LSPClient.Info[] - } + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 3fedd41d2c..d93670709e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -144,7 +144,6 @@ interface State { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) return evalRule(permission, pattern, ...rulesets) } diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index a97f3e9e8d..d520750035 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 -const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]) +const ALLOWED_MODELS = new Set([ + "gpt-5.5", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", +]) interface PkceCodes { verifier: string diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index ac37823c34..7a7f260df8 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,13 +3,14 @@ import type { PluginInput, Plugin as PluginInstance, PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, + WorkspaceAdapter as PluginWorkspaceAdapter, } from "@opencode-ai/plugin" import { Config } from "@/config/config" import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" @@ -24,8 +25,8 @@ import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" +import { registerAdapter } from "@/control-plane/adapters" +import type { WorkspaceAdapter } from "@/control-plane/types" const log = Log.create({ service: "plugin" }) @@ -124,11 +125,7 @@ export const layer = Layer.effect( const client = createOpencodeClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() @@ -138,8 +135,8 @@ export const layer = Layer.effect( worktree: ctx.worktree, directory: ctx.directory, experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + register(type: string, adapter: PluginWorkspaceAdapter) { + registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) }, }, get serverUrl(): URL { diff --git a/packages/opencode/src/project/bootstrap-service.ts b/packages/opencode/src/project/bootstrap-service.ts new file mode 100644 index 0000000000..b20cc54cd6 --- /dev/null +++ b/packages/opencode/src/project/bootstrap-service.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export * as InstanceBootstrap from "./bootstrap-service" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ea76b467d4..425c6edbba 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -5,40 +5,62 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" -import { Command } from "../command" import { Plugin } from "../plugin" import { InstanceState } from "@/effect/instance-state" -import * as Log from "@opencode-ai/core/util/log" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import * as Effect from "effect/Effect" +import { Effect, Layer } from "effect" import { Config } from "@/config/config" +import { Service } from "./bootstrap-service" -export const InstanceBootstrap = Effect.gen(function* () { - const ctx = yield* InstanceState.context - Log.Default.info("bootstrapping", { directory: ctx.directory }) - yield* Effect.all( - [ - Config.Service.use((i) => i.get()), - ...[ - Plugin.Service, - LSP.Service, - ShareNext.Service, - Format.Service, - File.Service, - FileWatcher.Service, - Vcs.Service, - Snapshot.Service, - ].map((s) => s.use((i) => i.init())), - ].map((e) => Effect.forkDetach(e)), - ).pipe(Effect.withSpan("InstanceBootstrap.init")) +export { Service } from "./bootstrap-service" +export type { Interface } from "./bootstrap-service" - const projectID = ctx.project.id - yield* Bus.Service.use((svc) => - svc.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }), - ) -}).pipe(Effect.withSpan("InstanceBootstrap")) +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + // Yield each bootstrap dep at layer init so `run` itself has R = never. + // InstanceStore imports only the lightweight tag from bootstrap-service.ts, + // so it can depend on bootstrap without importing this implementation graph. + const file = yield* File.Service + const fileWatcher = yield* FileWatcher.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const project = yield* Project.Service + const shareNext = yield* ShareNext.Service + const snapshot = yield* Snapshot.Service + const vcs = yield* Vcs.Service + + const run = Effect.gen(function* () { + const ctx = yield* InstanceState.context + yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + // Each service self-manages its own slow work via Effect.forkScoped against + // its per-instance state scope. We just await materialization here. + yield* Effect.forEach( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.withSpan("InstanceBootstrap.init")) + }).pipe(Effect.withSpan("InstanceBootstrap")) + + return Service.of({ run }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide([ + Bus.layer, + Config.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Plugin.defaultLayer, + Project.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, + Vcs.defaultLayer, + ]), +) + +export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 0000000000..b281f492d4 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,24 @@ +import { LocalContext } from "@/util/local-context" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import type * as Project from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") + +/** + * Check if a path is within the project boundary. + * Returns true if path is inside ctx.directory OR ctx.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ +export function containsPath(filepath: string, ctx: InstanceContext): boolean { + if (AppFileSystem.contains(ctx.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts new file mode 100644 index 0000000000..a7e2bfcb7b --- /dev/null +++ b/packages/opencode/src/project/instance-layer.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect" +import { InstanceStore } from "./instance-store" + +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts new file mode 100644 index 0000000000..c8803847a0 --- /dev/null +++ b/packages/opencode/src/project/instance-runtime.ts @@ -0,0 +1,16 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { type InstanceContext } from "./instance-context" +import { InstanceStore, type LoadInput } from "./instance-store" + +// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service. +// Delete this module once those callers are migrated to Effect boundaries that +// provide InstanceStore directly. + +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) +export const disposeInstance = (ctx: InstanceContext) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) +export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) + +export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 0000000000..4fa1c3dfff --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,191 @@ +import { GlobalBus } from "@/bus/global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceRef } from "@/effect/instance-ref" +import { disposeInstance as runDisposers } from "@/effect/instance-registry" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" +import { type InstanceContext } from "./instance-context" +import { InstanceBootstrap } from "./bootstrap-service" +import * as Project from "./project" + +export interface LoadInput { + directory: string + worktree?: string + project?: Project.Info +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +interface Entry { + readonly deferred: Deferred.Deferred +} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const bootstrap = yield* InstanceBootstrap.Service + const scope = yield* Scope.Scope + const cache = new Map() + + const boot = (input: LoadInput & { directory: string }) => + Effect.gen(function* () { + const ctx: InstanceContext = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) + + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (input: { directory: string; project?: string }) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: input.directory, + project: input.project, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: input.directory, + }, + }, + }), + ) + + const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { + yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => runDisposers(ctx.directory)) + yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) + }) + + const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) { + if (cache.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const load = (input: LoadInput): Effect.Effect => { + const directory = AppFileSystem.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = cache.get(directory) + if (existing) return yield* restore(Deferred.await(existing.deferred)) + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("creating instance", { directory }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.load")) + } + + const reload = (input: LoadInput): Effect.Effect => { + const directory = AppFileSystem.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const previous = cache.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("reloading instance", { directory }) + if (previous) { + yield* Deferred.await(previous.deferred).pipe(Effect.ignore) + yield* Effect.promise(() => runDisposers(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + } + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.reload")) + } + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + const entry = cache.get(ctx.directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) + }) + + const disposeAllOnce = Effect.fnUntraced(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + [...cache.entries()], + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, + ) + }) + + const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero) + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + return yield* cachedDisposeAll + }) + + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return Service.of({ + load, + reload, + dispose, + disposeAll, + provide, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 623e886231..a54291cf0c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,78 +1,8 @@ -import { GlobalBus } from "@/bus/global" -import { disposeInstance } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { iife } from "@/util/iife" -import * as Log from "@opencode-ai/core/util/log" -import { LocalContext } from "@/util/local-context" -import * as Project from "./project" -import { WorkspaceContext } from "@/control-plane/workspace-context" +import { context, type InstanceContext } from "./instance-context" -export interface InstanceContext { - directory: string - worktree: string - project: Project.Info -} - -const context = LocalContext.create("instance") -const cache = new Map>() -const project = makeRuntime(Project.Service, Project.defaultLayer) - -const disposal = { - all: undefined as Promise | undefined, -} - -function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await project - .runPromise((svc) => svc.fromDirectory(input.directory)) - .then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) -} - -function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task -} +export type { InstanceContext } from "./instance-context" export const Instance = { - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = AppFileSystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) - }, get current() { return context.use() }, @@ -86,19 +16,6 @@ export const Instance = { return context.use().project }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string, ctx?: InstanceContext) { - const instance = ctx ?? Instance - if (AppFileSystem.contains(instance.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (instance.worktree === "/") return false - return AppFileSystem.contains(instance.worktree, filepath) - }, /** * Captures the current instance ALS context and returns a wrapper that * restores it when called. Use this for callbacks that fire outside the @@ -116,75 +33,4 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return await next - }, - async dispose() { - const directory = Instance.directory - const project = Instance.project - Log.Default.info("disposing instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - - GlobalBus.emit("event", { - directory, - project: project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - }, - async disposeAll() { - if (disposal.all) return disposal.all - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } - - if (cache.get(key) !== value) continue - - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) - - return disposal.all - }, } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f30d2e90c7..a2c1a097b1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" @@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable Effect.Effect readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect @@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service > = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const bus = yield* Bus.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -417,6 +427,21 @@ export const layer: Layer.Layer< ) }) + const initState = yield* InstanceState.make( + Effect.fn("Project.initState")(function* (ctx) { + yield* bus.subscribe(Command.Event.Executed).pipe( + Stream.runForEach((payload) => + payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void, + ), + Effect.forkScoped, + ) + }), + ) + + const init = Effect.fn("Project.init")(function* () { + yield* InstanceState.get(initState) + }) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] @@ -466,6 +491,7 @@ export const layer: Layer.Layer< }) return Service.of({ + init, fromDirectory, discover, list, @@ -481,6 +507,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( + Layer.provide(Bus.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 24112cf442..8b3bedbf5b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,10 +1,8 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" @@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 -const count = (text: string) => { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) @@ -38,59 +27,170 @@ const merge = (...lists: Git.Item[][]) => { return [...out.values()] } +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Git.Patch) => { + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { + if (list.length === 0) return { patches: new Map(), capped: false } + + const result = yield* git.patchAll(cwd, ref, { + context: PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, +) { + const result = + item.code === "??" || !ref + ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + if (!result.truncated && result.text) return result.text + + if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) + return emptyPatch(item.file) +}) + +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES }) + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( + git: Git.Interface, + cwd: string, + ref: string | undefined, + item: Git.Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, +) { + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(git, cwd, ref, item) +}) + const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, git: Git.Interface, cwd: string, ref: string | undefined, list: Git.Item[], map: Map, + batch: { patches: Map; capped: boolean }, ) { - const base = ref ? yield* git.prefix(cwd) : "" - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = map.get(item.file) - return { - file: item.file, - patch: patch(item.file, before, after), - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) + const next: FileDiff[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next }) -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string | undefined, -) { - if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, nums(stats)) -}) - -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, - git: Git.Interface, - cwd: string, - ref: string, -) { +const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) return yield* files( - fs, git, cwd, ref, @@ -99,9 +199,15 @@ const compare = Effect.fnUntraced(function* ( extra.filter((item) => item.code === "??"), ), nums(stats), + yield* batchPatches(git, cwd, ref, list), ) }) +const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) + return yield* diffAgainstRef(git, cwd, ref) +}) + export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Mode = Schema.Schema.Type @@ -147,10 +253,9 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* AppFileSystem.Service const git = yield* Git.Service const bus = yield* Bus.Service const scope = yield* Scope.Scope @@ -204,23 +309,19 @@ export const layer: Layer.Layer(input: { directory: string; fn: () => R }): Promise { + const ctx = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: input.directory })), + ) + return context.provide(ctx, () => input.fn()) +} + +export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 170fe516c9..77e217eb7f 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,25 +1,13 @@ import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import path from "path" -import { Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Installation } from "../installation" import { Flag } from "@opencode-ai/core/flag/flag" -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" - -// Try to import bundled snapshot (generated at build time) -// Falls back to undefined in dev mode when snapshot doesn't exist -/* @ts-ignore */ - -const log = Log.create({ service: "models.dev" }) -const source = url() -const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, -) -const ttl = 5 * 60 * 1000 +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ input: Schema.Finite, @@ -101,76 +89,110 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type -function url() { - return Flag.OPENCODE_MODELS_URL || "https://models.dev" +export interface Interface { + readonly get: () => Effect.Effect> + readonly refresh: (force?: boolean) => Effect.Effect } -function fresh() { - return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl -} +export class Service extends Context.Service()("@opencode/ModelsDev") {} -function skip(force: boolean) { - return !force && fresh() -} +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) -const fetchApi = async () => { - const result = await fetch(`${url()}/api.json`, { - headers: { "User-Agent": Installation.USER_AGENT }, - signal: AbortSignal.timeout(10000), - }) - return { ok: result.ok, text: await result.text() } -} + const source = Flag.OPENCODE_MODELS_URL || "https://models.dev" + const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + ) + const ttl = Duration.minutes(5) + const lockKey = `models-dev:${filepath}` -export const Data = lazy(async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - // @ts-ignore - const snapshot = await import("./models-snapshot.js") - .then((m) => m.snapshot as Record) - .catch(() => undefined) - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - const result2 = await fetchApi() - if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) - } - return JSON.parse(result2.text) - }) -}) - -export async function get() { - const result = await Data() - return result as Record -} - -export async function refresh(force = false) { - if (skip(force)) return Data.reset() - await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return Data.reset() - const result = await fetchApi() - if (!result.ok) return - await Filesystem.write(filepath, result.text) - Data.reset() - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, + const fresh = Effect.fnUntraced(function* () { + const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat) return false + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() + return Date.now() - mtime < Duration.toMillis(ttl) }) - }) -} -if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - void refresh() - setInterval( - async () => { - await refresh() - }, - 60 * 1000 * 60, - ).unref() -} + const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { + return yield* HttpClientRequest.get(`${source}/api.json`).pipe( + HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT), + http.execute, + Effect.flatMap((res) => res.text), + Effect.timeout("10 seconds"), + ) + }) + + const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) + + // Bundled at build time; absent in dev — `tryPromise` covers both. + const loadSnapshot = Effect.tryPromise({ + // @ts-ignore — generated at build time, may not exist in dev + try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record | undefined), + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + yield* fs.writeWithDirs(filepath, text) + return text + }) + + const populate = Effect.gen(function* () { + const fromDisk = yield* loadFromDisk + if (fromDisk) return fromDisk + const snapshot = yield* loadSnapshot + if (snapshot) return snapshot + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + // Flock is cross-process: concurrent opencode CLIs can race on this cache file. + const text = yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + return yield* fetchAndWrite() + }), + ) + return JSON.parse(text) as Record + }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + + const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) + + const get = (): Effect.Effect> => cachedGet + + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + if (!force && (yield* fresh())) return + yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + // Re-check under the lock: another process may have refreshed between + // our outer check and lock acquisition. + if (!force && (yield* fresh())) return + yield* fetchAndWrite() + yield* invalidate + }), + ).pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), + Effect.ignore, + ) + }) + + if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { + // Schedule.spaced runs the effect once, then waits between completions. + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore)) + } + + return Service.of({ get, refresh }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), +) export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d9806d139..4013dcee36 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -138,6 +138,14 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + function custom(dep: CustomDep): Record { return { anthropic: () => @@ -222,12 +230,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { resourceName: resource, @@ -247,12 +250,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, @@ -1074,7 +1072,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const layer: Layer.Layer< Service, never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -1083,13 +1081,14 @@ const layer: Layer.Layer< const auth = yield* Auth.Service const env = yield* Env.Service const plugin = yield* Plugin.Service + const modelsDevSvc = yield* ModelsDev.Service const state = yield* InstanceState.make(() => Effect.gen(function* () { using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const modelsDev = yield* modelsDevSvc.get() const database = mapValues(modelsDev, fromModelsDevProvider) const providers: Record = {} as Record @@ -1722,6 +1721,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), ), ) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts new file mode 100644 index 0000000000..b5e5747c51 --- /dev/null +++ b/packages/opencode/src/pty/ticket.ts @@ -0,0 +1,68 @@ +export * as PtyTicket from "./ticket" + +import { WorkspaceID } from "@/control-plane/schema" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { PtyID } from "@/pty/schema" +import { PositiveInt } from "@/util/schema" +import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" + +const DEFAULT_TTL = Duration.seconds(60) +const CAPACITY = 10_000 + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}) + +export type Scope = { + readonly ptyID: PtyID + readonly directory?: string + readonly workspaceID?: WorkspaceID +} + +export interface Interface { + issue(input: Scope): Effect.Effect + consume(input: Scope & { readonly ticket: string }): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PtyTicket") {} + +function matches(record: Scope, input: Scope) { + return ( + record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID + ) +} + +// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is +// never invoked; it dies if it ever is, which would signal a misuse of the Service interface. +const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") + +// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. +export const make = (ttl: Duration.Input = DEFAULT_TTL) => + Effect.gen(function* () { + const cache = yield* Cache.make({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl }) + const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl)))) + return Service.of({ + issue: Effect.fn("PtyTicket.issue")(function* (input) { + const ticket = crypto.randomUUID() + yield* Cache.set(cache, ticket, input) + return { ticket, expires_in: expiresIn } + }), + consume: Effect.fn("PtyTicket.consume")(function* (input) { + return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input)) + }), + }) + }) + +export const layer = Layer.effect(Service, make()) + +export const defaultLayer = layer + +export const scope = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { + directory: instance?.directory, + workspaceID, + } +}) diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts new file mode 100644 index 0000000000..9630ddbe20 --- /dev/null +++ b/packages/opencode/src/server/auth.ts @@ -0,0 +1,48 @@ +export * as ServerAuth from "./auth" + +import { ConfigService } from "@/effect/config-service" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Config as EffectConfig, Context, Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export class Config extends ConfigService.Service()("@opencode/ServerAuthConfig", { + password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), + username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), +}) {} + +export type Info = Context.Service.Shape + +export function required(config: Info) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: Info) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 62a181af3a..92296a3b7d 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,7 +1,13 @@ +import { Context } from "effect" + const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ export type CorsOptions = { readonly cors?: ReadonlyArray } +export const CorsConfig = Context.Reference("@opencode/ServerCorsConfig", { + defaultValue: () => undefined, +}) + export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true @@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption if (opencodeOrigin.test(input)) return true return opts?.cors?.includes(input) ?? false } + +export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (host && sameHost(input, host)) return true + return isAllowedCorsOrigin(input, opts) +} + +function sameHost(origin: string, host: string) { + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index 7c5861d919..506e798187 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -21,6 +21,9 @@ export const ERRORS = { }, }, }, + 403: { + description: "Forbidden", + }, 404: { description: "Not found", content: { diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index aa784c90df..1b8c42c899 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -1,78 +1,8 @@ import type { MiddlewareHandler } from "hono" -import { Database } from "@/storage/db" -import { inArray } from "drizzle-orm" -import { EventSequenceTable } from "@/sync/event.sql" -import { Workspace } from "@/control-plane/workspace" -import type { WorkspaceID } from "@/control-plane/schema" import * as Log from "@opencode-ai/core/util/log" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" +import { HEADER, diff, load } from "./shared/fence" -const HEADER = "x-opencode-sync" -type State = Record -const log = Log.create({ service: "fence" }) - -export function load(ids?: string[]) { - const rows = Database.use((db) => { - if (!ids?.length) { - return db.select().from(EventSequenceTable).all() - } - - return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() - }) - - return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State -} - -export function diff(prev: State, next: State) { - const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) - return Object.fromEntries( - [...ids] - .map((id) => [id, next[id] ?? -1] as const) - .filter(([id, seq]) => { - return (prev[id] ?? -1) !== seq - }), - ) as State -} - -export function parse(headers: Headers) { - const raw = headers.get(HEADER) - if (!raw) return - - let data - - try { - data = JSON.parse(raw) - } catch { - return - } - - if (!data || typeof data !== "object") return - - return Object.fromEntries( - Object.entries(data).filter(([id, seq]) => { - return typeof id === "string" && Number.isInteger(seq) - }), - ) as State -} - -export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - return Effect.gen(function* () { - log.info("waiting for state", { - workspaceID, - state, - }) - yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) - log.info("state fully synced", { - workspaceID, - state, - }) - }) -} - -export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) -} +const log = Log.create({ service: "fence-middleware" }) export const FenceMiddleware: MiddlewareHandler = async (c, next) => { if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts new file mode 100644 index 0000000000..aa761a42b4 --- /dev/null +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -0,0 +1,37 @@ +import { GlobalBus } from "@/bus/global" +import { InstanceStore } from "@/project/instance-store" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { Event } from "./event" + +const log = Log.create({ service: "server" }) + +export const emitGlobalDisposed = Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), +) + +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( + function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("global disposal failed", { cause }) + }), + ), + ) + : store.disposeAll() + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) + }, +) + +export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts new file mode 100644 index 0000000000..5d29fae33f --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -0,0 +1,34 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import type { Opts } from "./adapter" +import { Service } from "./httpapi-server" + +export { Service } + +export const name = "node-http-server" + +export const layer = (opts: Opts) => { + const server = createServer() + const serverRef = { closeStarted: false, forceStop: false } + const close = server.close.bind(server) + // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by + // force-closing active HTTP sockets when its finalizer calls server.close(). + server.close = ((callback?: Parameters[0]) => { + serverRef.closeStarted = true + const result = close(callback) + if (serverRef.forceStop) server.closeAllConnections() + return result + }) as typeof server.close + return Layer.mergeAll( + NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + Layer.succeed(Service)( + Service.of({ + closeAll: Effect.sync(() => { + serverRef.forceStop = true + if (serverRef.closeStarted) server.closeAllConnections() + }), + }), + ), + ) +} diff --git a/packages/opencode/src/server/httpapi-server.ts b/packages/opencode/src/server/httpapi-server.ts new file mode 100644 index 0000000000..5f3804c107 --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiServer") {} + +export * as HttpApiServer from "./httpapi-server" diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index d2cc9b538d..160d258796 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -12,6 +12,8 @@ import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" +import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" +import { isPublicUIPath } from "./shared/public-ui" const log = Log.create({ service: "server" }) @@ -44,6 +46,8 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPublicUIPath(c.req.method, c.req.path)) return next() + if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) @@ -58,6 +62,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M const attributes = { method: c.req.method, path: c.req.path, + // If this logger grows full-URL fields, redact auth_token and ticket query params. ...backendAttributes, } log.info("request", attributes) diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 051d64c24d..069f308512 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" import * as Log from "@opencode-ai/core/util/log" -import * as Fence from "./fence" +import * as Fence from "./shared/fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 08f926d40d..788aef3176 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -2,40 +2,36 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { Effect } from "effect" -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { errorData } from "@/util/error" - -const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() .get( - "/adaptor", + "/adapter", describeRoute({ - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - operationId: "experimental.workspace.adaptor.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", + operationId: "experimental.workspace.adapter.list", responses: { 200: { - description: "Workspace adaptors", + description: "Workspace adapters", content: { "application/json": { - schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))), + schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), }, }, }, }, }), async (c) => { - return c.json(await listAdaptors(Instance.project.id)) + return c.json(await listAdapters(Instance.project.id)) }, ) .post( @@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() => }, ) .post( - "/:id/session-restore", + "/warp", describeRoute({ - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - operationId: "experimental.workspace.sessionRestore", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", + operationId: "experimental.workspace.warp", responses: { - 200: { - description: "Session replay started", - content: { - "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), - }, - }, + 204: { + description: "Session warped", }, ...errors(400), }, }), - validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), - validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), + validator( + "json", + z.object({ + id: zodObject(Workspace.Info).shape.id.nullable(), + sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + }), + ), async (c) => { - const { id } = c.req.valid("param") - const body = c.req.valid("json") as Omit - log.info("session restore route requested", { - workspaceID: id, - sessionID: body.sessionID, - directory: Instance.directory, - }) - try { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.sessionRestore({ - workspaceID: id, - ...body, - }), - ), - ) - log.info("session restore route complete", { - workspaceID: id, - sessionID: body.sessionID, - total: result.total, - }) - return c.json(result) - } catch (err) { - log.error("session restore route failed", { - workspaceID: id, - sessionID: body.sessionID, - error: errorData(err), - }) - throw err - } + const body = c.req.valid("json") + await AppRuntime.runPromise( + Workspace.Service.use((workspace) => + workspace.sessionWarp({ + workspaceID: body.id, + sessionID: body.sessionID, + }), + ), + ) + return c.body(null, 204) }, ), ) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index e78df61c2a..da3614d228 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -1,25 +1,24 @@ import { Hono, type Context } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import { streamSSE } from "hono/streaming" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" +import { Bus } from "@/bus" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { Instance } from "../../project/instance" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { lazy } from "../../util/lazy" import { Config } from "@/config/config" import { errors } from "../error" +import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" const log = Log.create({ service: "server" }) -export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({})) - async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { return streamSSE(c, async (stream) => { const q = new AsyncQueue() @@ -28,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.connected", properties: {}, }, @@ -39,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.heartbeat", properties: {}, }, @@ -178,8 +179,13 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info.zod), async (c) => { const config = c.req.valid("json") - const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) - return c.json(next) + const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) + if (result.changed) { + void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( + () => undefined, + ) + } + return c.json(result.info) }, ) .post( @@ -200,14 +206,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await Instance.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: GlobalDisposedEvent.type, - properties: {}, - }, - }) + await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md new file mode 100644 index 0000000000..c94fa64af7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/AGENTS.md @@ -0,0 +1,8 @@ +# Instance Route Parity + +This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. + +- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. +- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. +- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. +- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index f055917b0c..949734f81a 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -1,11 +1,16 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.config" }) export const ConfigRoutes = lazy(() => new Hono() @@ -51,13 +56,28 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info.zod), - async (c) => - jsonRequest("ConfigRoutes.update", c, function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - yield* cfg.update(config) - return config - }), + async (c) => { + const result = await runRequest( + "ConfigRoutes.update", + c, + Effect.gen(function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return { config, ctx: yield* InstanceState.context } + }), + ) + const response = c.json(result.config) + void runRequest( + "ConfigRoutes.update.dispose", + c, + InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ), + ) + return response + }, ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 474d92b31b..aeb1da5393 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -42,6 +42,7 @@ export const EventRoutes = () => q.push( JSON.stringify({ + id: Bus.createID(), type: "server.connected", properties: {}, }), @@ -51,6 +52,7 @@ export const EventRoutes = () => const heartbeat = setInterval(() => { q.push( JSON.stringify({ + id: Bus.createID(), type: "server.heartbeat", properties: {}, }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 81ea2394c0..1cf1584e3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" +import { V2Api } from "./groups/v2" // SSE event schemas built from the same BusEvent/SyncEvent registries that // the Hono spec uses, so both specs emit identical Event/SyncEvent components. @@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ProviderApi) .addHttpApi(SessionApi) .addHttpApi(SyncApi) + .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 25e810753e..a5c328ac0e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) { const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 272b086065..75441b4ca4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" +import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index d54bda4a84..3304ab9fbf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,4 +1,5 @@ import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -23,6 +24,7 @@ export const PtyPaths = { get: `${root}/:ptyID`, update: `${root}/:ptyID`, remove: `${root}/:ptyID`, + connectToken: `${root}/:ptyID/connect-token`, connect: `${root}/:ptyID/connect`, } as const @@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty") description: "Remove and terminate a specific pseudo-terminal (PTY) session.", }), ), + HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { + params: { ptyID: PtyID }, + success: described(PtyTicket.ConnectToken, "WebSocket connect token"), + error: [HttpApiError.Forbidden, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived ticket for opening a PTY WebSocket connection.", + }), + ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) @@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: described(Schema.Boolean, "Connected session"), - error: HttpApiError.NotFound, + error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connect", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 58d30b4c78..442e656554 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@/util/schema" +import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({ export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }) +export const SessionPayload = Schema.Struct({ + sessionID: SessionID, +}) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: Schema.String, @@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({ export const SyncPaths = { start: `${root}/start`, replay: `${root}/replay`, + steal: `${root}/steal`, history: `${root}/history`, } as const @@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync") description: "Validate and replay a complete sync event history.", }), ), + HttpApiEndpoint.post("steal", SyncPaths.steal, { + payload: SessionPayload, + success: described(SessionPayload, "Session stolen into workspace"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.steal", + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + }), + ), HttpApiEndpoint.post("history", SyncPaths.history, { payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts new file mode 100644 index 0000000000..05da5b720d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -0,0 +1,14 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { MessageGroup } from "./v2/message" +import { SessionGroup } from "./v2/session" + +export const V2Api = HttpApi.make("v2") + .add(SessionGroup) + .add(MessageGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts new file mode 100644 index 0000000000..3b0b2fa5b1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -0,0 +1,69 @@ +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const MessageGroup = HttpApiGroup.make("v2.message") + .add( + HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { + params: { sessionID: SessionID }, + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionMessagesQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionMessagesResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get v2 session messages", + description: + "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 messages", + description: "Experimental v2 message routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts new file mode 100644 index 0000000000..17ddcaeda3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -0,0 +1,140 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Prompt } from "@/v2/session-prompt" +import { SessionV2 } from "@/v2/session" +import { Schema, SchemaGetter } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const SessionGroup = HttpApiGroup.make("v2.session") + .add( + HttpApiEndpoint.get("sessions", "/api/session", { + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspace: WorkspaceID.pipe(Schema.optional), + roots: Schema.Literals(["true", "false"]) + .pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), + ) + .pipe(Schema.optional), + start: Schema.NumberFromString.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + directory: Schema.optional(Schema.Never), + path: Schema.optional(Schema.Never), + workspace: Schema.optional(Schema.Never), + roots: Schema.optional(Schema.Never), + start: Schema.optional(Schema.Never), + search: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionsQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionV2.Info), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionsResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List v2 sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + prompt: Prompt, + delivery: SessionV2.Delivery.pipe(Schema.optional), + }), + success: SessionMessage.Message, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send v2 message", + description: "Create a v2 session message and queue it for the agent loop.", + }), + ), + ) + .add( + HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact v2 session", + description: "Compact a v2 session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for v2 session", + description: "Wait for a v2 session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { + params: { sessionID: SessionID }, + success: Schema.Array(SessionMessage.Message), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get v2 session context", + description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2", + description: "Experimental v2 routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 268e84f2ec..f197ab9765 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,8 +1,7 @@ import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" -import { NonNegativeInt } from "@/util/schema" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { Schema, Struct } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" @@ -10,30 +9,30 @@ import { described } from "./metadata" const root = "/experimental/workspace" export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) -export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) -export const SessionRestoreResponse = Schema.Struct({ - total: NonNegativeInt, +export const WarpPayload = Schema.Struct({ + id: Schema.NullOr(Workspace.Info.fields.id), + sessionID: Workspace.SessionWarpInput.fields.sessionID, }) export const WorkspacePaths = { - adaptors: `${root}/adaptor`, + adapters: `${root}/adapter`, list: root, status: `${root}/status`, remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, + warp: `${root}/warp`, } as const export const WorkspaceApi = HttpApi.make("workspace") .add( HttpApiGroup.make("workspace") .add( - HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { - success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"), + HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.adaptor.list", - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", + identifier: "experimental.workspace.adapter.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { @@ -76,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Remove an existing workspace.", }), ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: described(SessionRestoreResponse, "Session replay started"), + HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + payload: WarpPayload, + success: described(HttpApiSchema.NoContent, "Session warped"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", + identifier: "experimental.workspace.warp", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", }), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 58aa81098c..753ba03138 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -16,7 +16,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h }) const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) + yield* configSvc.update(ctx.payload) yield* markInstanceForDisposal(yield* InstanceState.context) return ctx.payload }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index cd1bebec47..f80869b64d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,9 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { EffectBridge } from "@/effect/bridge" +import { Bus } from "@/bus" import { Installation } from "@/installation" -import { Instance } from "@/project/instance" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -42,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), @@ -68,6 +70,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service + const bridge = yield* EffectBridge.make() const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -82,15 +85,13 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) + const result = yield* config.updateGlobal(ctx.payload) + if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })) + return result.info }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) + yield* disposeAllInstancesAndEmitGlobalDisposed() return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index ae2761ac32..3c1dd350db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -1,6 +1,5 @@ import { AppRuntime } from "@/effect/app-runtime" import * as InstanceState from "@/effect/instance-state" -import { InstanceBootstrap } from "@/project/bootstrap" import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Effect } from "effect" @@ -29,7 +28,6 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", directory: ctx.directory, worktree: ctx.directory, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return next }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index c8689eabab..f9df530a92 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index cc7c385b3e..e5ff300a2a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,17 +1,32 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" +import { EffectBridge } from "@/effect/bridge" +import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" +import { WebSocketTracker } from "../websocket-tracker" + +function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) { + return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts) +} export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig const shells = Effect.fn("PtyHttpApi.shells")(function* () { return yield* Effect.promise(() => Shell.list()) @@ -52,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler return true }) + const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) + return yield* new HttpApiError.Forbidden({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) + }) + return handlers .handle("shells", shells) .handle("list", list) @@ -59,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("get", get) .handle("update", update) .handle("remove", remove) + .handle("connectToken", connectToken) }), ) export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig yield* router.add( "GET", PtyPaths.connect, @@ -73,16 +99,37 @@ export const ptyConnectRoute = HttpRouter.use((router) => if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const request = yield* HttpServerRequest.HttpServerRequest + const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + const valid = validOrigin(request, cors) + ? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) const cursor = parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const socket = yield* Effect.orDie(request.upgrade) const write = yield* socket.writer - const services = yield* Effect.context() + const closeAccepted = (event: Socket.CloseEvent) => + socket + .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT())) + if (!registered) { + yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT()) + return HttpServerResponse.empty() + } + const bridge = yield* EffectBridge.make() const writeScoped = (effect: Effect.Effect) => { - Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void))) + bridge.fork(effect.pipe(Effect.catch(() => Effect.void))) } let closed = false const adapter = { @@ -100,7 +147,10 @@ export const ptyConnectRoute = HttpRouter.use((router) => }, } const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() + if (!handler) { + yield* closeAccepted(new Socket.CloseEvent(4404, "session not found")) + return HttpServerResponse.empty() + } yield* socket .runRaw((message) => handlePtyInput(handler, message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 8cc969f483..4a67ba036e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,7 +18,7 @@ import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" -import { Cause, Effect, Schema, Scope } from "effect" +import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) url.searchParams.set("before", page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index f4a2f315cd..152d22f98e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,5 +1,6 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" import { EventTable } from "@/sync/event.sql" @@ -12,7 +13,7 @@ import { or } from "drizzle-orm" import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { HistoryPayload, ReplayPayload } from "../groups/sync" +import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "server.sync" }) @@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl return { sessionID: source } }) + const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) { + const workspaceID = yield* InstanceState.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + yield* sync.run(Session.Event.Updated, { + sessionID: ctx.payload.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: ctx.payload.sessionID, + workspaceID, + }) + + return { sessionID: ctx.payload.sessionID } + }) + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return Database.use((db) => @@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl ) }) - return handlers.handle("start", start).handle("replay", replay).handle("history", history) + return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index c7c447ce85..cc85321685 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -5,7 +5,7 @@ import * as Database from "@/storage/db" import { eq } from "drizzle-orm" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../../tui" +import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control" import { InstanceHttpApi } from "../api" import { CommandPayload, TuiPublishPayload } from "../groups/tui" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts new file mode 100644 index 0000000000..55cb534581 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -0,0 +1,6 @@ +import { SessionV2 } from "@/v2/session" +import { Layer } from "effect" +import { messageHandlers } from "./v2/message" +import { sessionHandlers } from "./v2/session" + +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts new file mode 100644 index 0000000000..3485d80fd6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -0,0 +1,60 @@ +import { SessionMessage } from "@/v2/session-message" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import * as DateTime from "effect/DateTime" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultMessagesLimit = 50 + +const Cursor = Schema.Struct({ + id: SessionMessage.ID, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), +}) + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +const cursor = { + encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from( + JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers.handle( + "messages", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const messages = yield* session.messages({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit ?? DefaultMessagesLimit, + order, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = messages[0] + const last = messages.at(-1) + return { + items: messages, + cursor: { + previous: first ? cursor.encode(first, order, "previous") : undefined, + next: last ? cursor.encode(last, order, "next") : undefined, + }, + } + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts new file mode 100644 index 0000000000..558e34dd18 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -0,0 +1,115 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultSessionsLimit = 50 + +const SessionCursor = Schema.Struct({ + id: SessionV2.Info.fields.id, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspaceID: WorkspaceID.pipe(Schema.optional), + roots: Schema.Boolean.pipe(Schema.optional), + start: Schema.Finite.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), +}) +type SessionCursor = typeof SessionCursor.Type + +const decodeCursor = Schema.decodeUnknownSync(SessionCursor) + +const sessionCursor = { + encode( + session: SessionV2.Info, + order: "asc" | "desc", + direction: "previous" | "next", + filters: Pick, + ) { + return Buffer.from( + JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers + .handle( + "sessions", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const filters = decoded ?? { + directory: ctx.query.directory, + path: ctx.query.path, + workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + } + const sessions = yield* session.list({ + limit: ctx.query.limit ?? DefaultSessionsLimit, + order, + directory: filters.directory, + path: filters.path, + workspaceID: filters.workspaceID, + roots: filters.roots, + start: filters.start, + search: filters.search, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + items: sessions, + cursor: { + previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined, + next: last ? sessionCursor.encode(last, order, "next", filters) : undefined, + }, + } + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return yield* session.prompt({ + sessionID: ctx.params.sessionID, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, + }) + }), + ) + .handle( + "compact", + Effect.fn(function* (ctx) { + yield* session.compact(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "wait", + Effect.fn(function* (ctx) { + yield* session.wait(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "context", + Effect.fn(function* (ctx) { + return yield* session.context(ctx.params.sessionID) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 4e76a76a30..b415943a62 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,18 +1,18 @@ -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, SessionRestorePayload } from "../groups/workspace" +import { CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { const workspace = yield* Workspace.Service - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () { const instance = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(instance.project.id)) + return yield* Effect.promise(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { @@ -24,6 +24,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace .create({ ...ctx.payload, + extra: ctx.payload.extra ?? null, projectID: instance.project.id, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) @@ -38,24 +39,21 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace.remove(ctx.params.id) }) - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - return yield* workspace - .sessionRestore({ - workspaceID: ctx.params.id, + const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + yield* workspace + .sessionWarp({ + workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) return handlers - .handle("adaptors", adaptors) + .handle("adapters", adapters) .handle("list", list) .handle("create", create) .handle("status", status) .handle("remove", remove) - .handle("sessionRestore", sessionRestore) + .handle("warp", warp) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 7b263980c5..53d54e2a81 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,13 +1,13 @@ -import type { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { WorkspaceRef } from "@/effect/instance-ref" -import { Instance, type InstanceContext } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" type MarkedInstance = { ctx: InstanceContext - workspaceID?: WorkspaceID + store: InstanceStore.Interface + bridge: EffectBridge.Shape } // Disposal is requested by an endpoint handler, but must run from the outer @@ -17,20 +17,9 @@ const disposeAfterResponse = new WeakMap() const mark = (ctx: InstanceContext) => Effect.gen(function* () { - return { ctx, workspaceID: yield* WorkspaceRef } + return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() } }) -// Instance.dispose/reload still publish events through legacy ALS helpers. -// Effect request handlers carry these values in services, so bridge them back -// into the legacy contexts only around the lifecycle operation. -const restoreMarked = (marked: MarkedInstance, fn: () => A) => - Effect.promise(() => - WorkspaceContext.provide({ - workspaceID: marked.workspaceID, - fn: () => Instance.restore(marked.ctx, fn), - }), - ) - export const markInstanceForDisposal = (ctx: InstanceContext) => Effect.gen(function* () { const marked = yield* mark(ctx) @@ -43,11 +32,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => ) }) -export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => +export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) => Effect.gen(function* () { const marked = yield* mark(ctx) return yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response), + Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response), ) }) @@ -58,6 +47,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose())) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index e022a568ac..6f5648f30a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,71 +1,52 @@ -import { ConfigService } from "@/effect/config-service" -import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { ServerAuth } from "@/server/auth" +import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" +import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" +import { isPublicUIPath } from "@/server/shared/public-ui" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 +const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' +// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the +// full handler, so a downstream failure can make the next auth alternative run +// and remap an authorized NotFound into Unauthorized. export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { error: HttpApiError.UnauthorizedNoContent, - security: { - basic: HttpApiSecurity.basic, - authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }), - }, }, ) {} -export class ServerAuthConfig extends ConfigService.Service()( - "@opencode/ExperimentalHttpApiServerAuthConfig", - { - password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), - username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), - }, -) {} +function emptyCredential() { + return { + username: "", + password: Redacted.make(""), + } +} function validateCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { return Effect.gen(function* () { - if (!isAuthRequired(config)) return yield* effect - if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) + if (!ServerAuth.required(config)) return yield* effect + if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) return yield* effect }) } -function isAuthRequired(config: Context.Service.Shape) { - return Option.isSome(config.password) && config.password.value !== "" -} - -function isCredentialAuthorized( - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, -) { - return ( - Option.isSome(config.password) && - credential.username === config.username && - Redacted.value(credential.password) === config.password.value - ) -} - function decodeCredential(input: string) { - const emptyCredential = { - username: "", - password: Redacted.make(""), - } - return Encoding.decodeBase64String(input) .asEffect() .pipe( Effect.match({ - onFailure: () => emptyCredential, + onFailure: emptyCredential, onSuccess: (header) => { const parts = header.split(":") - if (parts.length !== 2) return emptyCredential + if (parts.length !== 2) return emptyCredential() return { username: parts[0], password: Redacted.make(parts[1]), @@ -75,40 +56,48 @@ function decodeCredential(input: string) { ) } +function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { + return credentialFromURL(new URL(request.url, "http://localhost"), request) +} + +function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) { + const token = url.searchParams.get(AUTH_TOKEN_QUERY) + if (token) return decodeCredential(token) + const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") + if (match) return decodeCredential(match[1]) + return Effect.succeed(emptyCredential()) +} + function validateRawCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { - if (!isAuthRequired(config)) return effect - if (!isCredentialAuthorized(credential, config)) - return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED })) + if (!ServerAuth.required(config)) return effect + if (!ServerAuth.authorized(credential, config)) + return Effect.succeed( + HttpServerResponse.empty({ + status: UNAUTHORIZED, + headers: { "www-authenticate": WWW_AUTHENTICATE }, + }), + ) return effect } export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { - const config = yield* ServerAuthConfig - if (!isAuthRequired(config)) return (effect) => effect + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return (effect) => effect return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") - if (match) { - return yield* decodeCredential(match[1]).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) - if (token) { - return yield* decodeCredential(token).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config) + const url = new URL(request.url, "http://localhost") + if (isPublicUIPath(request.method, url.pathname)) return yield* effect + if (hasPtyConnectTicketURL(url)) return yield* effect + return yield* credentialFromURL(url, request).pipe( + Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + ) }) }), ) @@ -116,13 +105,15 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { - const config = yield* ServerAuthConfig - return Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential, config), - authToken: (effect, { credential }) => - decodeCredential(Redacted.value(credential)).pipe( - Effect.flatMap((decoded) => validateCredential(effect, decoded, config)), - ), - }) + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return Authorization.of((effect) => effect) + return Authorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => validateCredential(effect, credential, config)), + ) + }), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts new file mode 100644 index 0000000000..6f3c33a647 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -0,0 +1,58 @@ +import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { NotFoundError } from "@/storage/storage" +import { iife } from "@/util/iife" +import { NamedError } from "@opencode-ai/core/util/error" +import * as Log from "@opencode-ai/core/util/log" +import { Cause, Effect } from "effect" +import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" + +const log = Log.create({ service: "server" }) + +// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. +export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + effect.pipe( + Effect.catchCause((cause) => { + const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { + if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false + if (HttpServerError.isHttpServerError(reason.defect)) return false + if (HttpServerRespondable.isRespondable(reason.defect)) return false + return true + }) + if (!defect) return Effect.failCause(cause) + + const error = defect.defect + log.error("failed", { error, cause: Cause.pretty(cause) }) + + if (error instanceof NamedError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(error.toObject(), { + status: iife(() => { + if (error instanceof NotFoundError) return 404 + if (error instanceof Provider.ModelNotFoundError) return 400 + if (error.name === "ProviderAuthValidationFailed") return 400 + if (error.name.startsWith("Worktree")) return 400 + return 500 + }), + }), + ) + } + if (error instanceof Session.BusyError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), { + status: 400, + }), + ) + } + + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ + message: error instanceof Error && error.stack ? error.stack : String(error), + }).toObject(), + { status: 500 }, + ), + ) + }), + ), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index c80f1caeb6..d4913696d2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,9 +1,5 @@ -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import type { InstanceContext } from "@/project/instance" -import { Filesystem } from "@/util/filesystem" +import { WorkspaceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -24,32 +20,30 @@ function decode(input: string): string { } } -function makeInstanceContext(directory: string): Effect.Effect { - return Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(directory)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) -} - function provideInstanceContext( effect: Effect.Effect, + store: InstanceStore.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(route.directory) - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, route.workspaceID), + return yield* store.provide( + { directory: decode(route.directory) }, + effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) } -export const instanceContextLayer = Layer.succeed( +export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + }), ) -export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) +export const instanceRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return (effect) => provideInstanceContext(effect, store) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e354dccbfa..230f5b105b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WebSocketTracker } from "../websocket-tracker" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { return request.source instanceof Request ? request.source : undefined @@ -28,6 +29,33 @@ export function websocket( }) const writeInbound = yield* inbound.writer const writeOutbound = yield* outbound.writer + const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect) => + socket + .runRaw(() => Effect.void, { + onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)), + }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], { + concurrency: "unbounded", + discard: true, + }) + const registered = yield* WebSocketTracker.register( + Effect.all( + [ + writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + ], + { concurrency: "unbounded", discard: true }, + ), + ) + if (!registered) { + yield* closeAccepted + return HttpServerResponse.empty() + } yield* outbound .runRaw((message) => writeInbound(message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 30edbc782b..a91a9992df 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -1,12 +1,12 @@ -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" -import { Instance } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" -import * as Fence from "@/server/fence" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import * as Fence from "@/server/shared/fence" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" @@ -43,14 +43,6 @@ export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< } >()("@opencode/ExperimentalHttpApiWorkspaceRouting") {} -function currentDirectory(): string { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - function requestURL(request: HttpServerRequest.HttpServerRequest): URL { return new URL(request.url, "http://localhost") } @@ -65,7 +57,7 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp } function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { - return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory() + return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd() } function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { @@ -88,10 +80,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe } function resolveTarget(workspace: Workspace.Info): Effect.Effect { - return Effect.gen(function* () { - const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) - }) + const adapter = getAdapter(workspace.projectID, workspace.type) + return EffectBridge.fromPromise(() => adapter.target(workspace)) } function proxyRemote( diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 17d6e0d063..c9668336ae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -39,6 +39,7 @@ type OpenApiSchema = { maximum?: number minimum?: number oneOf?: OpenApiSchema[] + pattern?: string prefixItems?: OpenApiSchema[] properties?: Record required?: string[] @@ -74,9 +75,18 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record +const PathParameterSchemas = { + sessionID: { type: "string", pattern: "^ses.*" }, + messageID: { type: "string", pattern: "^msg.*" }, + partID: { type: "string", pattern: "^prt.*" }, + permissionID: { type: "string", pattern: "^per.*" }, + ptyID: { type: "string", pattern: "^pty.*" }, +} satisfies Record + const LegacyComponentDescriptions = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", @@ -428,6 +438,11 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { /** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { + if (schema.allOf?.length === 1) { + const [constraint] = schema.allOf + delete schema.allOf + return stripOptionalNull({ ...schema, ...constraint }) + } if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { @@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | } function normalizeParameter(param: OpenApiParameter, route: string) { - if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] - if (override) { - param.schema = override + if (!param.schema || typeof param.schema !== "object") return + if (param.in === "path") { + param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + if (param.in === "query") { + const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + if (override) { + param.schema = override + return + } + if (QueryNumberParameters.has(param.name)) { + param.schema = { type: "number" } + return + } + if (QueryBooleanParameters.has(param.name)) { + param.schema = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + } + return } - return } param.schema = stripOptionalNull(param.schema) } +function pathParameterSchema(route: string, name: string) { + if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } + if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } + return undefined +} + export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e6dedfe2c4..ef966036a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -11,16 +11,21 @@ import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" +import { FileWatcher } from "@/file/watcher" import { Ripgrep } from "@/file/ripgrep" import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceLayer } from "@/project/instance-layer" +import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -31,17 +36,20 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" +import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" +import { Snapshot } from "@/snapshot" import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" -import { serveUIEffect } from "@/server/routes/ui" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { serveUIEffect } from "@/server/shared/ui" +import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" -import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" +import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -58,12 +66,14 @@ import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" +import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { errorLayer } from "./middleware/error" export const context = Context.makeUnsafe(new Map()) @@ -90,7 +100,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer)) const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( Layer.provide(eventHandlers), Layer.provide(instanceRouterLayer), @@ -109,6 +119,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( providerHandlers, sessionHandlers, syncHandlers, + v2Handlers, tuiHandlers, workspaceHandlers, ]), @@ -117,7 +128,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), + authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), @@ -129,11 +140,12 @@ const uiRoute = HttpRouter.use((router) => const client = yield* HttpClient.HttpClient yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) +).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ + errorLayer, cors(corsOptions), runtime, Account.defaultLayer, @@ -142,15 +154,19 @@ export function createRoutes(corsOptions?: CorsOptions) { Command.defaultLayer, Config.defaultLayer, File.defaultLayer, + FileWatcher.defaultLayer, Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, MCP.defaultLayer, + ModelsDev.defaultLayer, Permission.defaultLayer, + Plugin.defaultLayer, Project.defaultLayer, ProviderAuth.defaultLayer, Provider.defaultLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, @@ -161,18 +177,22 @@ export function createRoutes(corsOptions?: CorsOptions) { SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, SyncEvent.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, ToolRegistry.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Bus.layer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)), + Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts new file mode 100644 index 0000000000..7cbac4ed5f --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -0,0 +1,57 @@ +import { Context, Effect, Layer, Option } from "effect" +import * as Socket from "effect/unstable/socket/Socket" + +export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") + +type Close = Effect.Effect + +export interface Interface { + readonly add: (close: Close) => Effect.Effect + readonly remove: (close: Close) => Effect.Effect + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} + +export const layer = Layer.sync(Service)(() => { + const sockets = new Set() + let closing = false + return Service.of({ + add: (close) => + Effect.gen(function* () { + if (closing) return false + sockets.add(close) + return true + }), + remove: (close) => + Effect.sync(() => { + sockets.delete(close) + }), + closeAll: Effect.gen(function* () { + closing = true + const active = Array.from(sockets) + sockets.clear() + yield* Effect.all( + active.map((close) => + close.pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.void), + ), + ), + { concurrency: "unbounded", discard: true }, + ) + }), + }) +}) + +export const register = (close: Close) => + Effect.gen(function* () { + const tracker = yield* Effect.serviceOption(Service) + if (Option.isNone(tracker)) return true + const registered = yield* tracker.value.add(close) + if (!registered) return false + yield* Effect.addFinalizer(() => tracker.value.remove(close)) + return true + }) + +export * as WebSocketTracker from "./websocket-tracker" diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index fa11e3e90d..71662dea90 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -1,11 +1,13 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" +import { Context, Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" +import { InstanceRuntime } from "@/project/instance-runtime" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -26,13 +28,139 @@ import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" +import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" +import { ExperimentalPaths } from "./httpapi/groups/experimental" +import { FilePaths } from "./httpapi/groups/file" +import { InstancePaths } from "./httpapi/groups/instance" +import { McpPaths } from "./httpapi/groups/mcp" +import { PtyPaths } from "./httpapi/groups/pty" +import { SessionPaths } from "./httpapi/groups/session" +import { SyncPaths } from "./httpapi/groups/sync" +import { TuiPaths } from "./httpapi/groups/tui" +import { WorkspacePaths } from "./httpapi/groups/workspace" +import type { CorsOptions } from "@/server/cors" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { +export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() + const handler = ExperimentalHttpApiServer.webHandler(opts).handler + const context = Context.empty() as Context.Context + + app.all("/api/*", (c) => handler(c.req.raw, context)) + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) + app.patch("/config", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + app.post("/project/git/init", (c) => handler(c.req.raw, context)) + app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) + app.get(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) + app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) + app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) + app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) + } return app .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) + .route("/pty", PtyRoutes(upgrade, opts)) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) @@ -62,7 +190,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await Instance.dispose() + await InstanceRuntime.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 19918b8b48..23707faf79 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,7 +1,5 @@ import type { MiddlewareHandler } from "hono" -import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -22,9 +20,8 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler return WorkspaceContext.provide({ workspaceID, async fn() { - return Instance.provide({ + return WithInstance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index b9f86b1839..3d8bb605bd 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,13 +2,12 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" +import { InstanceRuntime } from "@/project/instance-runtime" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { InstanceBootstrap } from "@/project/bootstrap" -import { AppRuntime } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,12 +81,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) + await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index cc67355901..8ff7bc3103 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() => const svc = yield* Provider.Service const cfg = yield* Config.Service const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index bff0b71915..fb8d5e356d 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import type { Context } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" import { Effect, Schema } from "effect" @@ -6,10 +7,19 @@ import z from "zod" import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { Shell } from "@/shell/shell" import { NotFoundError } from "@/storage/storage" import { errors } from "../../error" import { jsonRequest, runRequest } from "./trace" +import { HTTPException } from "hono/http-exception" +import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" +import { zod as effectZod } from "@/util/effect-zod" const ShellItem = z.object({ path: z.string(), @@ -18,7 +28,11 @@ const ShellItem = z.object({ }) const decodePtyID = Schema.decodeUnknownSync(PtyID) -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { +function validOrigin(c: Context, opts?: CorsOptions) { + return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) +} + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { return new Hono() .get( "/shells", @@ -175,6 +189,43 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return true }), ) + .post( + "/:ptyID/connect-token", + describeRoute({ + summary: "Create PTY WebSocket token", + description: "Create a short-lived token for opening a PTY WebSocket connection.", + operationId: "pty.connectToken", + responses: { + 200: { + description: "WebSocket connect token", + content: { + "application/json": { + schema: resolver(effectZod(PtyTicket.ConnectToken)), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) + throw new HTTPException(403) + const result = await runRequest( + "PtyRoutes.connectToken", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = c.req.valid("param").ptyID + if (!(yield* pty.get(id))) return + const tickets = yield* PtyTicket.Service + return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!result) throw new NotFoundError({ message: "Session not found" }) + return c.json(result) + }, + ) .get( "/:ptyID/connect", describeRoute({ @@ -190,7 +241,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }, - ...errors(404), + ...errors(403, 404), }, }), validator("param", z.object({ ptyID: PtyID.zod })), @@ -201,14 +252,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { } const id = decodePtyID(c.req.param("ptyID")) - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined if ( !(await runRequest( "PtyRoutes.connect", @@ -219,8 +262,29 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), )) ) { - throw new Error("Session not found") + throw new NotFoundError({ message: "Session not found" }) } + const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + if (!validOrigin(c, opts)) throw new HTTPException(403) + const valid = await runRequest( + "PtyRoutes.connect.ticket", + c, + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!valid) throw new HTTPException(403) + } + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Handler | undefined type Socket = { readyState: number diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b7bf413d4e..9894d8c8ee 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" +import { Session } from "@/session/session" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { SessionID } from "@/session/schema" const ReplayEvent = z.object({ id: z.string(), @@ -24,6 +27,9 @@ const ReplayEvent = z.object({ type: z.string(), data: z.record(z.string(), z.unknown()), }) +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) const log = Log.create({ service: "server.sync" }) @@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() => }) }, ) + .post( + "/steal", + describeRoute({ + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + operationId: "sync.steal", + responses: { + 200: { + description: "Session stolen into workspace", + content: { + "application/json": { + schema: resolver(SessionPayload), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + const workspaceID = WorkspaceContext.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + SyncEvent.run(Session.Event.Updated, { + sessionID: body.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: body.sessionID, + workspaceID, + }) + + return c.json({ + sessionID: body.sessionID, + }) + }, + ) .post( "/history", describeRoute({ diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 48399a5f4d..a7a0c9cbdc 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -7,36 +7,24 @@ import { Session } from "@/session/session" import type { SessionID } from "@/session/schema" import { TuiEvent } from "@/cli/cmd/tui/event" import { zodObject } from "@/util/effect-zod" -import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { runRequest } from "./trace" - -export const TuiRequest = z.object({ - path: z.string(), - body: z.any(), -}) - -export type TuiRequest = z.infer - -const request = new AsyncQueue() -const response = new AsyncQueue() - -export function nextTuiRequest() { - return request.next() -} - -export function submitTuiResponse(body: unknown) { - response.push(body) -} +import { + TuiRequest, + nextTuiRequest, + nextTuiResponse, + submitTuiRequest, + submitTuiResponse, +} from "@/server/shared/tui-control" export async function callTui(ctx: Context) { const body = await ctx.req.json() - request.push({ + submitTuiRequest({ path: ctx.req.path, body, }) - return response.next() + return nextTuiResponse() } const TuiControlRoutes = new Hono() diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 403d85d66c..ce06b2b35e 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,53 +1,10 @@ -import { Flag } from "@opencode-ai/core/flag/flag" +import fs from "node:fs/promises" +import { createHash } from "node:crypto" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Effect, Stream } from "effect" -import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Hono } from "hono" import { proxy } from "hono/proxy" -import { getMimeType } from "hono/utils/mime" -import { createHash } from "node:crypto" -import fs from "node:fs/promises" import { ProxyUtil } from "../proxy-util" - -const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI - ? Promise.resolve(null) - : // @ts-expect-error - generated file at build time - import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) - -const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" -const UI_UPSTREAM = new URL("https://app.opencode.ai") - -const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` - -function themePreloadHash(body: string) { - return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) -} - -function requestBody(request: HttpServerRequest.HttpServerRequest) { - if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty - const len = request.headers["content-length"] - return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) -} - -function proxyResponseHeaders(headers: Record) { - const result = new Headers(headers) - // FetchHttpClient exposes decoded response bodies, so forwarding upstream - // transfer metadata makes browsers decode already-decoded assets again. - result.delete("content-encoding") - result.delete("content-length") - return result -} - -function upstreamURL(path: string) { - return new URL(path, UI_UPSTREAM).toString() -} - -function embeddedUI() { - if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) - return embeddedUIPromise -} +import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -58,7 +15,7 @@ export async function serveUI(request: Request) { if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) if (await fs.exists(match)) { - const mime = getMimeType(match) ?? "text/plain" + const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) return new Response(new Uint8Array(await fs.readFile(match)), { headers }) @@ -79,49 +36,4 @@ export async function serveUI(request: Request) { return response } -export function serveUIEffect( - request: HttpServerRequest.HttpServerRequest, - services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, -) { - return Effect.gen(function* () { - const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) - const path = new URL(request.url, "http://localhost").pathname - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - - if (yield* services.fs.existsSafe(match)) { - const mime = getMimeType(match) ?? "text/plain" - const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) - } - - return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - } - - const response = yield* services.client.execute( - HttpClientRequest.make(request.method)(upstreamURL(path), { - headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), - body: requestBody(request), - }), - ) - const headers = proxyResponseHeaders(response.headers) - - if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text - const match = themePreloadHash(body) - headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) - return HttpServerResponse.text(body, { status: response.status, headers }) - } - - headers.set("Content-Security-Policy", csp()) - return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { - status: response.status, - headers, - }) - }) -} - export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6ebc8dc487..ca86599955 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,6 +5,10 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" +import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -17,6 +21,9 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" +import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" +import { PublicApi } from "./routes/instance/httpapi/public" import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" @@ -113,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), runtime, } } @@ -129,13 +136,36 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .route("/", ControlPlaneRoutes()) .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) .route("/", UIRoutes()), runtime, } } +/** + * Generate the OpenAPI document used by the SDK build. + * + * Since the Effect HttpApi backend now covers every Hono route (plus the new + * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity + * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. + * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` + * transform that injects instance query parameters, strips Effect's optional + * null arms, normalizes component names, and patches SSE response schemas so + * the generated SDK keeps the legacy Hono shape. + * + * The Hono-derived spec is still reachable via `openapiHono()` so reviewers + * can diff the two outputs while the Hono backend lingers; once the Hono + * backend is deleted that helper goes with it. + */ export async function openapi() { + return OpenApi.fromApi(PublicApi) +} + +/** + * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once + * the Hono backend is removed. + */ +export async function openapiHono() { // Build a fresh app with all routes registered directly so // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which @@ -157,37 +187,151 @@ export async function openapi() { export let url: URL export async function listen(opts: ListenOptions): Promise { - const built = create(opts) - const server = await built.runtime.listen(opts) + const selected = select() + const inner: Listener = + selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) - const next = new URL("http://localhost") - next.hostname = opts.hostname - next.port = String(server.port) + const next = new URL(inner.url) url = next const mdns = - opts.mdns && - server.port && - opts.hostname !== "127.0.0.1" && - opts.hostname !== "localhost" && - opts.hostname !== "::1" + opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { - MDNS.publish(server.port, opts.mdnsDomain) + MDNS.publish(inner.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } let closing: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } + return { + hostname: inner.hostname, + port: inner.port, + url: next, + stop(close?: boolean) { + unpublish() + // Always forward stop(true), even if a graceful stop was requested + // first, so native listeners can escalate shutdown in-place. + const next = inner.stop(close) + closing ??= next + return close ? next.then(() => closing!) : closing + }, + } +} + +async function listenLegacy(opts: ListenOptions): Promise { + const built = create(opts) + const server = await built.runtime.listen(opts) + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(server.port) return { hostname: opts.hostname, port: server.port, - url: next, - stop(close?: boolean) { - closing ??= (async () => { - if (mdns) MDNS.unpublish() - await server.stop(close) - })() - return closing + url: innerUrl, + stop: (close?: boolean) => server.stop(close), + } +} + +/** + * Run the effect-httpapi backend on a native Effect HTTP server. This + * lets HttpApi routes that call `request.upgrade` (PTY connect, the + * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono + * adapter path can't surface `request.upgrade` because its fetch handler has + * no reference to the platform server instance for websocket upgrades. + */ +async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { + log.info("server backend selected", { + ...ServerBackend.attributes(selection), + "opencode.server.runtime": HttpApiServer.name, + }) + + const buildLayer = (port: number) => + HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { + middleware: disposeMiddleware, + disableLogger: true, + disableListenLog: true, + }).pipe( + Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + // Install a fresh `ConfigProvider` per listener so `Config.string(...)` + // reads reflect the current `process.env`. Effect's default + // `ConfigProvider` snapshots `process.env` on first read and caches the + // result on a module-singleton Reference; without overriding it here, + // every later `Server.listen()` keeps observing that initial snapshot. + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), + ) + + const start = async (port: number) => { + const scope = Scope.makeUnsafe() + try { + // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by + // design, which leaks `R = any` through `HttpRouter.serve`. The actual + // requirements at this point are fully satisfied by `createRoutes` and the + // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. + const layer = buildLayer(port) as Layer.Layer< + HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, + unknown, + never + > + const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope)) + return { scope, ctx } + } catch (err) { + await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined) + throw err + } + } + + // Match the legacy adapter port-resolution behavior: explicit `0` prefers + // 4096 first, then any free port. + let resolved: Awaited> | undefined + if (opts.port === 0) { + resolved = await start(4096).catch(() => undefined) + if (!resolved) resolved = await start(0) + } else { + resolved = await start(opts.port) + } + if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`) + + const server = Context.get(resolved.ctx, HttpServer.HttpServer) + if (server.address._tag !== "TcpAddress") { + await Effect.runPromise(Scope.close(resolved.scope, Exit.void)) + throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`) + } + const port = server.address.port + + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(port) + let forceStopPromise: Promise | undefined + let stopPromise: Promise | undefined + const forceStop = () => { + forceStopPromise ??= Effect.runPromiseExit( + Effect.gen(function* () { + yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll + yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll + }), + ).then(() => undefined) + return forceStopPromise + } + + return { + hostname: opts.hostname, + port, + url: innerUrl, + stop: (close?: boolean) => { + const requested = close ? forceStop() : Promise.resolve() + // The first call starts scope shutdown. A later stop(true) cannot undo + // that, but it still runs forceStop() before awaiting the original close. + stopPromise ??= requested + .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) + .then(() => undefined) + return requested.then(() => stopPromise!) }, } } diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts new file mode 100644 index 0000000000..659764970b --- /dev/null +++ b/packages/opencode/src/server/shared/fence.ts @@ -0,0 +1,74 @@ +import { Database } from "@/storage/db" +import { inArray } from "drizzle-orm" +import { EventSequenceTable } from "@/sync/event.sql" +import { Workspace } from "@/control-plane/workspace" +import type { WorkspaceID } from "@/control-plane/schema" +import * as Log from "@opencode-ai/core/util/log" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" + +export const HEADER = "x-opencode-sync" +export type State = Record +const log = Log.create({ service: "fence" }) + +export function load(ids?: string[]) { + const rows = Database.use((db) => { + if (!ids?.length) { + return db.select().from(EventSequenceTable).all() + } + + return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + }) + + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State +} + +export function diff(prev: State, next: State) { + const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) + return Object.fromEntries( + [...ids] + .map((id) => [id, next[id] ?? -1] as const) + .filter(([id, seq]) => { + return (prev[id] ?? -1) !== seq + }), + ) as State +} + +export function parse(headers: Headers) { + const raw = headers.get(HEADER) + if (!raw) return + + let data + + try { + data = JSON.parse(raw) + } catch { + return + } + + if (!data || typeof data !== "object") return + + return Object.fromEntries( + Object.entries(data).filter(([id, seq]) => { + return typeof id === "string" && Number.isInteger(seq) + }), + ) as State +} + +export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + return Effect.gen(function* () { + log.info("waiting for state", { + workspaceID, + state, + }) + yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) + log.info("state fully synced", { + workspaceID, + state, + }) + }) +} + +export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) +} diff --git a/packages/opencode/src/server/shared/pty-ticket.ts b/packages/opencode/src/server/shared/pty-ticket.ts new file mode 100644 index 0000000000..0efd06e6a7 --- /dev/null +++ b/packages/opencode/src/server/shared/pty-ticket.ts @@ -0,0 +1,15 @@ +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/ + +// Auth middleware skips Basic Auth when this matches; the PTY connect handler +// is then responsible for validating the ticket. +export function isPtyConnectPath(pathname: string) { + return PTY_CONNECT_PATH.test(pathname) +} + +export function hasPtyConnectTicketURL(url: URL) { + return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts new file mode 100644 index 0000000000..fece09592f --- /dev/null +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -0,0 +1,12 @@ +// Static UI assets the browser fetches without app-managed credentials, e.g. +// the manifest link in . These bypass auth so the page can install/render +// the manifest icons even when a server password is configured. +export const PUBLIC_UI_PATHS = new Set([ + "/site.webmanifest", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", +]) + +export function isPublicUIPath(method: string, pathname: string) { + return method === "GET" && PUBLIC_UI_PATHS.has(pathname) +} diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts new file mode 100644 index 0000000000..40aaf04a96 --- /dev/null +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -0,0 +1,28 @@ +import z from "zod" +import { AsyncQueue } from "@/util/queue" + +export const TuiRequest = z.object({ + path: z.string(), + body: z.any(), +}) + +export type TuiRequest = z.infer + +const request = new AsyncQueue() +const response = new AsyncQueue() + +export function nextTuiRequest() { + return request.next() +} + +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + +export function submitTuiResponse(body: unknown) { + response.push(body) +} + +export function nextTuiResponse() { + return response.next() +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts new file mode 100644 index 0000000000..40d8aa7afb --- /dev/null +++ b/packages/opencode/src/server/shared/ui.ts @@ -0,0 +1,105 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Stream } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createHash } from "node:crypto" +import { ProxyUtil } from "../proxy-util" + +const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI + ? Promise.resolve(null) + : // @ts-expect-error - generated file at build time + import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) + +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +export const UI_UPSTREAM = new URL("https://app.opencode.ai") + +export const csp = (hash = "") => + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` + +export function themePreloadHash(body: string) { + return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) +} + +function proxyResponseHeaders(headers: Record) { + const result = new Headers(headers) + // FetchHttpClient exposes decoded response bodies, so forwarding upstream + // transfer metadata makes browsers decode already-decoded assets again. + result.delete("content-encoding") + result.delete("content-length") + result.delete("transfer-encoding") + return result +} + +export function upstreamURL(path: string) { + return new URL(path, UI_UPSTREAM).toString() +} + +export function embeddedUI() { + if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) + return embeddedUIPromise +} + +function notFound() { + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) +} + +function embeddedUIResponse(file: string, body: Uint8Array) { + const mime = AppFileSystem.mimeType(file) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(body, { headers }) +} + +export function serveEmbeddedUIEffect( + requestPath: string, + fs: AppFileSystem.Interface, + embeddedWebUI: Record, +) { + const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!file) return Effect.succeed(notFound()) + + return fs.readFile(file).pipe( + Effect.map((body) => embeddedUIResponse(file, body)), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), + ) +} + +export function serveUIEffect( + request: HttpServerRequest.HttpServerRequest, + services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, +) { + return Effect.gen(function* () { + const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) + const path = new URL(request.url, "http://localhost").pathname + + if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) + + const response = yield* services.client.execute( + HttpClientRequest.make(request.method)(upstreamURL(path), { + headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), + body: requestBody(request), + }), + ) + const headers = proxyResponseHeaders(response.headers) + + if (response.headers["content-type"]?.includes("text/html")) { + const body = yield* response.text + const match = themePreloadHash(body) + headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) + return HttpServerResponse.text(body, { status: response.status, headers }) + } + + headers.set("Content-Security-Policy", csp()) + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + headers, + }) + }) +} diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts new file mode 100644 index 0000000000..366c455dd6 --- /dev/null +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -0,0 +1,36 @@ +import { SessionID } from "@/session/schema" + +type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } + +const RULES: Array = [ + { path: "/experimental/workspace", action: "local" }, + { path: "/session/status", action: "forward" }, + { method: "GET", path: "/session", action: "local" }, +] + +export function isLocalWorkspaceRoute(method: string, path: string) { + for (const rule of RULES) { + if (rule.method && rule.method !== method) continue + const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") + if (match) return rule.action === "local" + } + return false +} + +export function getWorkspaceRouteSessionID(url: URL) { + if (url.pathname === "/session/status") return null + + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + return SessionID.make(id) +} + +export function workspaceProxyURL(target: string | URL, requestURL: URL) { + const proxyURL = new URL(target) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` + proxyURL.search = requestURL.search + proxyURL.hash = requestURL.hash + proxyURL.searchParams.delete("workspace") + return proxyURL +} diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 667e610abc..0972875305 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -1,53 +1,17 @@ import type { MiddlewareHandler } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" -import { Session } from "@/session/session" -import { SessionID } from "@/session/schema" import { AppRuntime } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" +import { Session } from "@/session/session" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" - -type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } - -const RULES: Array = [ - { path: "/experimental/workspace", action: "local" }, - { path: "/session/status", action: "forward" }, - { method: "GET", path: "/session", action: "local" }, -] - -export function isLocalWorkspaceRoute(method: string, path: string) { - for (const rule of RULES) { - if (rule.method && rule.method !== method) continue - const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") - if (match) return rule.action === "local" - } - return false -} - -export function getWorkspaceRouteSessionID(url: URL) { - if (url.pathname === "/session/status") return null - - const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] - if (!id) return null - - return SessionID.make(id) -} - -export function workspaceProxyURL(target: string | URL, requestURL: URL) { - const proxyURL = new URL(target) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` - proxyURL.search = requestURL.search - proxyURL.hash = requestURL.hash - proxyURL.searchParams.delete("workspace") - return proxyURL -} +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" async function getSessionWorkspace(url: URL) { const id = getWorkspaceRouteSessionID(url) @@ -91,16 +55,15 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const adaptor = getAdaptor(workspace.projectID, workspace.type) - const target = await adaptor.target(workspace) + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = await adapter.target(workspace) if (target.type === "local") { return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => - Instance.provide({ + WithInstance.provide({ directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2fe..067d43da2e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,10 +14,13 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context, Schema } from "effect" +import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" const log = Log.create({ service: "session.compaction" }) @@ -556,7 +559,21 @@ export const layer: Layer.Layer< } if (processor.message.error) return "stop" - if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + if (result === "continue") { + const summary = summaryText( + (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { + info: msg, + parts: [], + }, + ) + EventV2.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + } return result }) @@ -583,6 +600,11 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) + EventV2.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) }) return Service.of({ diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 911f58efd0..5f97074b20 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -772,7 +772,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return { type: "content", value: [ - { type: "text", text: outputObject.text }, + ...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), ...attachments.map((attachment) => ({ type: "media", mediaType: attachment.mime, @@ -938,10 +938,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { + if (differentModel) { + if (part.text.trim().length > 0) + assistantMessage.parts.push({ + type: "text", + text: part.text, + }) + continue + } assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c59..f22da92927 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,10 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" +import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -221,6 +225,12 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -248,6 +258,13 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -260,6 +277,13 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -281,13 +305,34 @@ export const layer: Layer.Layer< case "tool-input-delta": return - case "tool-input-end": + case "tool-input-end": { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) return + } case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -331,11 +376,48 @@ export const layer: Layer.Layer< } case "tool-result": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: value.output.metadata, + content: [ + { + type: "text", + text: value.output.output, + }, + ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Failed.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* failToolCall(value.toolCallId, value.error) return } @@ -345,6 +427,20 @@ export const layer: Layer.Layer< case "start-step": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -355,18 +451,30 @@ export const layer: Layer.Layer< return case "finish-step": { + const completedSnapshot = yield* snapshot.track() const usage = Session.getUsage({ model: ctx.model, usage: value.usage, metadata: value.providerMetadata, }) + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), reason: value.finishReason, - snapshot: yield* snapshot.track(), + snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", @@ -404,6 +512,13 @@ export const layer: Layer.Layer< } case "text-start": + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -442,6 +557,14 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -528,6 +651,17 @@ export const layer: Layer.Layer< yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: "unknown", + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, @@ -568,13 +702,24 @@ export const layer: Layer.Layer< Effect.retry( SessionRetry.policy({ parse, - set: (info) => - status.set(ctx.sessionID, { + set: (info) => { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + return status.set(ctx.sessionID, { type: "retry", attempt: info.attempt, message: info.message, next: info.next, - }), + }) + }, }), ), Effect.catch(halt), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts new file mode 100644 index 0000000000..93298170cc --- /dev/null +++ b/packages/opencode/src/session/projectors-next.ts @@ -0,0 +1,203 @@ +import { and, desc, eq } from "@/storage/db" +import type { Database } from "@/storage/db" +import { SessionMessage } from "@/v2/session-message" +import { SessionMessageUpdater } from "@/v2/session-message-updater" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" +import { SyncEvent } from "@/sync" +import { SessionMessageTable, SessionTable } from "./session.sql" +import type { SessionID } from "./schema" +import { Schema } from "effect" + +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) +type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]> + +function encodeDateTimes(value: unknown): unknown { + if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value) + if (Array.isArray(value)) return value.map(encodeDateTimes) + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)])) + } + return value +} + +function encodeMessageData(value: unknown): SessionMessageData { + return encodeDateTimes(value) as SessionMessageData +} + +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter { + return { + getCurrentAssistant() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) + }, + getCurrentCompaction() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Compaction => message.type === "compaction") + }, + getCurrentShell(callID) { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + }, + updateAssistant(assistant) { + const { id, type, ...data } = assistant + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateCompaction(compaction) { + const { id, type, ...data } = compaction + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateShell(shell) { + const { id, type, ...data } = shell + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + appendMessage(message) { + const { id, type, ...data } = message + db.insert(SessionMessageTable) + .values([ + { + id, + session_id: sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data: encodeMessageData(data), + }, + ]) + .run() + }, + finish() {}, + } +} + +function update(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event) +} + +export default [ + SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + agent: data.agent, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data }) + }), + SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + model: data.model, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data }) + }), + SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) + }), + SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) + }), + SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) + }), + SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) + }), + SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) + }), + SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) + }), + SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) + }), + SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) + }), + SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) + }), + SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data }) + }), +] diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index a3832ebe65..9819ad810f 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" +import nextProjectors from "./projectors-next" const log = Log.create({ service: "session.projector" }) @@ -136,4 +137,6 @@ export default [ log.warn("ignored late part update", { partID: id, messageID, sessionID }) } }), + + ...nextProjectors, ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fb822ff17e..8286ecf8e6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import z from "zod" import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -41,6 +40,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -53,6 +53,14 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" +import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import * as DateTime from "effect/DateTime" +import { eq } from "@/storage/db" +import * as Database from "@/storage/db" +import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -112,9 +120,8 @@ export const layer = Layer.effect( return yield* EffectBridge.make() }) const ops = Effect.fn("SessionPrompt.ops")(function* () { - const run = yield* runner() return { - cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), + cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input), } satisfies TaskPromptOps @@ -464,9 +471,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result: Awaited>> = yield* Effect.promise(() => - execute(args, opts), + const result: Awaited>> = yield* Effect.gen(function* () { + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => execute(args, opts)) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": key, + "tool.call_id": opts.toolCallId, + "session.id": ctx.sessionID, + "message.id": input.processor.message.id, + }, + }), ) yield* plugin.trigger( "tool.execute.after", @@ -775,20 +791,28 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } yield* sessions.updateMessage(msg) + const callID = ulid() + const started = Date.now() const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: ShellID.ToolID, callID: ulid(), state: { status: "running", - time: { start: Date.now() }, + time: { start: started }, input: { command: input.command }, }, } yield* sessions.updatePart(part) + EventV2.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -803,14 +827,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } + const completed = Date.now() + EventV2.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) if (!msg.time.completed) { - msg.time.completed = Date.now() + msg.time.completed = completed yield* sessions.updateMessage(msg) } if (part.state.status === "running") { part.state = { status: "completed", - time: { ...part.state.time, end: Date.now() }, + time: { ...part.state.time, end: completed }, input: part.state.input, title: "", metadata: { output, description: "" }, @@ -924,6 +955,36 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + if (current?.agent !== info.agent) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }) + } + if ( + current?.model?.providerID !== info.model.providerID || + current.model.id !== info.model.modelID || + current.model.variant !== info.model.variant + ) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + }, + }) + } + yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never @@ -1240,6 +1301,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) + const nextPrompt = parts.reduce( + (result, part) => { + if (part.type === "text") { + if (part.synthetic) result.synthetic.push(part.text) + else result.text.push(part.text) + } + if (part.type === "file") { + result.files.push( + new FileAttachment({ + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? new Source({ + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + }) + : undefined, + }), + ) + } + if (part.type === "agent") { + result.agents.push( + new AgentAttachment({ + name: part.name, + source: part.source + ? new Source({ + start: part.source.start, + end: part.source.end, + text: part.source.value, + }) + : undefined, + }), + ) + } + return result + }, + { + text: [] as string[], + files: [] as FileAttachment[], + agents: [] as AgentAttachment[], + synthetic: [] as string[], + }, + ) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Prompted.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, + }) + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } return { info, parts } }, Effect.scoped) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d65..421fa68694 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionEntry } from "../v2/session-entry" +import type { SessionMessage } from "../v2/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( "session", @@ -34,6 +35,12 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), + agent: text(), + model: text({ mode: "json" }).$type<{ + id: string + providerID: string + variant?: string + }>(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -96,22 +103,22 @@ export const TodoTable = sqliteTable( ], ) -export const SessionEntryTable = sqliteTable( - "session_entry", +export const SessionMessageTable = sqliteTable( + "session_message", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type>(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_entry_session_idx").on(table.session_id), - index("session_entry_session_type_idx").on(table.session_id, table.type), - index("session_entry_time_created_idx").on(table.time_created), + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), ], ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5593efc971..09d2c8c3c3 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" @@ -78,6 +79,14 @@ export function fromRow(row: SessionRow): Info { path: row.path ?? undefined, parentID: row.parent_id ?? undefined, title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, version: row.version, summary, share, @@ -102,6 +111,8 @@ export function toRow(info: Info) { directory: info.directory, path: info.path, title: info.title, + agent: info.agent, + model: info.model, version: info.version, share_url: info.share?.url, summary_additions: info.summary?.additions, @@ -142,9 +153,9 @@ const Share = Schema.Struct({ url: Schema.String, }) -// Legacy HTTP accepted any number here, and persisted data may already contain -// negative values. Keep archive timestamps permissive while other clocks stay non-negative. -export const ArchivedTimestamp = Schema.Number +// Legacy HTTP accepted negative values here. Keep archive timestamps permissive +// while excluding non-finite values that cannot round-trip through JSON. +export const ArchivedTimestamp = Schema.Finite const Time = Schema.Struct({ created: NonNegativeInt, @@ -160,6 +171,12 @@ const Revert = Schema.Struct({ diff: optionalOmitUndefined(Schema.String), }) +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -171,6 +188,8 @@ export const Info = Schema.Struct({ summary: optionalOmitUndefined(Summary), share: optionalOmitUndefined(Share), title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), version: Schema.String, time: Time, permission: optionalOmitUndefined(Permission.Ruleset), @@ -201,6 +220,8 @@ export const CreateInput = Schema.optional( Schema.Struct({ parentID: Schema.optional(SessionID), title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -272,6 +293,8 @@ const UpdatedInfo = Schema.Struct({ summary: Schema.optional(Schema.NullOr(Summary)), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), @@ -404,6 +427,8 @@ export interface Interface { readonly create: (input?: { parentID?: SessionID title?: string + agent?: string + model?: Schema.Schema.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect @@ -464,6 +489,8 @@ export const layer: Layer.Layer parentID?: SessionID workspaceID?: WorkspaceID directory: string @@ -481,6 +508,8 @@ export const layer: Layer.Layer permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -601,6 +632,8 @@ export const layer: Layer.Layer { }) export function close() { + if (!Client.loaded()) return Client().$client.close() Client.reset() } diff --git a/packages/opencode/src/sync/README.md b/packages/opencode/src/sync/README.md index 546cf3ced4..cb7e875648 100644 --- a/packages/opencode/src/sync/README.md +++ b/packages/opencode/src/sync/README.md @@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes ### Event shape -- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus. +- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus. The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. @@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. -The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this). +The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this). -It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: +It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: ```ts // The schema from `Updated` typechecks the object correctly diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/opencode/src/sync/event.sql.ts index b51b5a5dfe..547a80f0f3 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/opencode/src/sync/event.sql.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), seq: integer().notNull(), + owner_id: text(), }) export const EventTable = sqliteTable("event", { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 324c4ec45f..62b30ccf9a 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -46,7 +46,7 @@ export type Properties = EffectSchema.Schem export type SerializedEvent = Event & { type: string } -type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise type PublishContext = { instance?: InstanceContext @@ -59,8 +59,11 @@ export interface Interface { data: Event["data"], options?: { publish?: boolean }, ) => Effect.Effect - readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect - readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect + readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { publish: boolean; ownerID?: string }, + ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect } @@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)( const row = Database.use((db) => db - .select({ seq: EventSequenceTable.seq }) + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) .get(), @@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)( const latest = row?.seq ?? -1 if (event.seq <= latest) return + if (row?.ownerID && row.ownerID !== options?.ownerID) { + return + } + const expected = latest + 1 if (event.seq !== expected) { throw new Error( @@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context }) + process(def, event, { publish, context, ownerID: options?.ownerID }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -255,7 +262,7 @@ export function define< export function project( def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, + func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { return [def, func as ProjectorFunc] } @@ -263,7 +270,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext }, + options: { publish: boolean; context?: PublishContext; ownerID?: string }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -274,16 +281,15 @@ function process( throw new Error(`Projector not found for event: ${def.type}`) } - // idempotent: need to ignore any events already logged - Database.transaction((tx) => { - projector(tx, event.data) + projector(tx, event.data, event) if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { tx.insert(EventSequenceTable) .values({ aggregate_id: event.aggregateID, seq: event.seq, + owner_id: options?.ownerID, }) .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, @@ -308,7 +314,7 @@ function process( } const result = convertEvent(def.type, event.data) - const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) + const publish = (data: unknown) => ProjectBus.publish(def, data as Properties, { id: event.id }) if (result instanceof Promise) { void result.then(publish) } else { @@ -332,11 +338,11 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean }) { +export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replay(event, options)) } -export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { +export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replayAll(events, options)) } @@ -348,6 +354,16 @@ export function remove(aggregateID: string) { return runtime.runSync((sync) => sync.remove(aggregateID)) } +export function claim(aggregateID: string, ownerID: string) { + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ) +} + export function payloads() { return registry .entries() diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 0dd9a1af30..23d416b53e 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import type * as Tool from "./tool" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import { AppFileSystem } from "@opencode-ai/core/filesystem" type Kind = "file" | "directory" @@ -24,7 +24,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec const ins = yield* InstanceState.context const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target - if (Instance.containsPath(full, ins)) return + if (containsPath(full, ins)) return const kind = options?.kind ?? "file" const dir = kind === "directory" ? full : path.dirname(full) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ef33a48dea..bf01fc7d2d 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import DESCRIPTION from "./read.txt" import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" -import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,6 +18,7 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const SAMPLE_BYTES = 4096 +const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) // `offset` and `limit` were originally `z.coerce.number()` — the runtime // coercion was useful when the tool was called from a shell but serves no @@ -153,10 +154,6 @@ export const ReadTool = Tool.define( params: Schema.Schema.Type, ctx: Tool.Context, ) { - if (params.offset !== undefined && params.offset < 1) { - return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) - } - const instance = yield* InstanceState.context let filepath = params.filePath if (!path.isAbsolute(filepath)) { @@ -191,7 +188,7 @@ export const ReadTool = Tool.define( if (stat.type === "Directory") { const items = yield* list(filepath) const limit = params.limit ?? DEFAULT_READ_LIMIT - const offset = params.offset ?? 1 + const offset = params.offset || 1 const start = offset - 1 const sliced = items.slice(start, start + limit) const truncated = start + sliced.length < items.length @@ -220,7 +217,9 @@ export const ReadTool = Tool.define( const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) - if (isImageAttachment(mime) || isPdfAttachment(mime)) { + const isImage = SUPPORTED_IMAGE_MIMES.has(mime) + + if (isImage || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { @@ -246,7 +245,7 @@ export const ReadTool = Tool.define( } const file = yield* Effect.promise(() => - lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }), + lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }), ) if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) { return yield* Effect.fail( diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a9a853e504..a4eb31acc7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" -import { BashTool } from "./bash" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -106,7 +106,7 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const bash = yield* BashTool + const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -154,7 +154,16 @@ export const layer: Layer.Layer< ...(out.truncated && { outputPath: out.outputPath }), }, } - }), + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": id, + "session.id": toolCtx.sessionID, + "message.id": toolCtx.messageID, + ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}), + }, + }), + ), } } @@ -186,7 +195,7 @@ export const layer: Layer.Layer< const tool = yield* Effect.all({ invalid: Tool.init(invalid), - bash: Tool.init(bash), + shell: Tool.init(shell), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -208,7 +217,7 @@ export const layer: Layer.Layer< builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), - tool.bash, + tool.shell, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 80% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index fe3e45d66f..d3ca542684 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,12 +1,11 @@ -import { Schema } from "effect" -import { PositiveInt } from "@/util/schema" +import { Effect, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" import path from "path" -import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" -import { Instance, type InstanceContext } from "../project/instance" +import { containsPath, type InstanceContext } from "../project/instance-context" +import { InstanceState } from "@/effect/instance-state" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -14,20 +13,21 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" import { Shell } from "@/shell/shell" +import { ShellID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" -import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { InstanceState } from "@/effect/instance-state" +import { ShellPrompt, type Parameters } from "./shell/prompt" +import { BashArity } from "@/permission/arity" + +export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -const CWD = new Set(["cd", "push-location", "set-location"]) +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, "rm", @@ -50,21 +50,23 @@ const FILES = new Set([ "new-item", "rename-item", ]) +const CMD_FILES = new Set([ + "copy", + "del", + "dir", + "erase", + "md", + "mkdir", + "move", + "rd", + "ren", + "rename", + "rmdir", + "type", +]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ - description: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - }), -}) - type Part = { type: string text: string @@ -81,7 +83,7 @@ type Chunk = { size: number } -export const log = Log.create({ service: "bash-tool" }) +export const log = Log.create({ service: "shell-tool" }) const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) @@ -187,11 +189,16 @@ function prefix(text: string) { return text.slice(0, match.index) } -function pathArgs(list: Part[], ps: boolean) { +function pathArgs(list: Part[], ps: boolean, cmd = false) { if (!ps) { return list .slice(1) - .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .filter( + (item) => + !item.text.startsWith("-") && + !(cmd && item.text.startsWith("/")) && + !(list[0]?.text === "chmod" && item.text.startsWith("+")), + ) .map((item) => item.text) } @@ -251,13 +258,13 @@ function tail(text: string, maxLines: number, maxBytes: number) { } } -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -273,7 +280,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) if (scan.patterns.size === 0) return yield* ctx.ask({ - permission: "bash", + permission: ShellID.ToolID, patterns: Array.from(scan.patterns), always: Array.from(scan.always), metadata: {}, @@ -325,9 +332,8 @@ const parser = lazy(async () => { return { bash, ps } }) -// TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define( - "bash", +export const ShellTool = Tool.define( + ShellID.ToolID, Effect.gen(function* () { const config = yield* Config.Service const spawner = yield* ChildProcessSpawner @@ -335,7 +341,7 @@ export const BashTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -344,7 +350,7 @@ export const BashTool = Tool.define( return AppFileSystem.normalizePath(file) }) - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { const file = yield* cygpath(shell, text) @@ -355,7 +361,7 @@ export const BashTool = Tool.define( return path.resolve(root, text) }) - const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) const file = text && prefix(text) if (!file || dynamic(file, ps)) return @@ -364,7 +370,7 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* ( + const collect = Effect.fn("ShellTool.collect")(function* ( root: Node, cwd: string, ps: boolean, @@ -376,17 +382,18 @@ export const BashTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellID.toKind(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0] - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { + if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) { + for (const arg of pathArgs(command, ps, shellKind === "cmd")) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved, instance)) continue + if (!resolved || containsPath(resolved, instance)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) } @@ -401,7 +408,7 @@ export const BashTool = Tool.define( return scan }) - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -413,7 +420,7 @@ export const BashTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string command: string @@ -527,7 +534,7 @@ export const BashTool = Tool.define( const meta: string[] = [] if (expired) { meta.push( - `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } if (aborted) meta.push("User aborted the command") @@ -546,7 +553,7 @@ export const BashTool = Tool.define( } if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" + output += "\n\n\n" + meta.join("\n") + "\n" } if (sink) { const stream = sink @@ -577,25 +584,14 @@ export const BashTool = Tool.define( const cfg = yield* config.get() const shell = Shell.acceptable(cfg.shell) const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) - const limits = yield* trunc.limits() - const instance = yield* InstanceState.context + const prompt = ShellPrompt.render(name, process.platform, limits) + log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", instance.directory) - .replaceAll("${tmp}", Global.Path.tmp) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(limits.maxLines)) - .replaceAll("${maxBytes}", String(limits.maxBytes)), - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + description: prompt.description, + parameters: prompt.parameters, + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const executeInstance = yield* InstanceState.context const cwd = params.workdir @@ -612,7 +608,7 @@ export const BashTool = Tool.define( Effect.sync(() => tree.delete()), ) const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 0000000000..061253f8fb --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +const kinds = ["bash", "pwsh", "powershell", "cmd"] as const +export type Kind = (typeof kinds)[number] + +const shellKinds = new Set(kinds) + +function isKind(value: string): value is Kind { + return shellKinds.has(value) +} + +export function toKind(value: string): Kind { + return isKind(value) ? value : "bash" +} + +// Keep the exposed tool ID and permission key as "bash" for compatibility with +// existing plugins, users, and saved permissions. Rename with opencode 2.0. +export const ToolID = "bash" +export type ToolID = typeof ToolID + +export * as ShellID from "./id" diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 0000000000..45c637863a --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,295 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" +import { PositiveInt } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { ShellID } from "./id" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before bash for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo\\" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + tmp: Global.Path.tmp, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: ShellID.ToolID, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 59% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index a131ed7e63..5cba07805c 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,54 +1,14 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +${workdirSection} Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - +${commandSection} # Committing changes with git @@ -67,7 +27,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -84,18 +44,18 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote @@ -104,11 +64,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + - ${createPrInstruction} -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> +${createPrExample} Important: diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e58ea9b122..22e4e5671c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,10 +6,11 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Schema } from "effect" +import { Effect, Exit, Schema } from "effect" +import { EffectBridge } from "@/effect/bridge" export interface TaskPromptOps { - cancel(sessionID: SessionID): void + cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } @@ -118,16 +119,18 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + const runCancel = yield* EffectBridge.make() const messageID = MessageID.ascending() + const cancel = ops.cancel(nextSession.id) - function cancel() { - ops.cancel(nextSession.id) + function onAbort() { + runCancel.fork(cancel) } return yield* Effect.acquireUseRelease( Effect.sync(() => { - ctx.abort.addEventListener("abort", cancel) + ctx.abort.addEventListener("abort", onAbort) }), () => Effect.gen(function* () { @@ -163,10 +166,16 @@ export const TaskTool = Tool.define( ].join("\n"), } }), - () => - Effect.sync(() => { - ctx.abort.removeEventListener("abort", cancel) - }), + (_, exit) => + Effect.gen(function* () { + if (Exit.hasInterrupts(exit)) yield* cancel + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.abort.removeEventListener("abort", onAbort) + }), + ), + ), ) }) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..1c88712d7d 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index fbda2dc50e..dabc6dfe18 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -7,7 +7,19 @@ export function errorFormat(error: unknown): string { if (typeof error === "object" && error !== null) { try { - return JSON.stringify(error, null, 2) + const json = JSON.stringify(error, null, 2) + // Plain objects whose own properties are all non-enumerable (or empty) + // serialize to "{}", which prints as a useless bare `{}` on stderr. + // Fall back to a custom toString first, then to ctor name + own prop names. + if (json === "{}") { + const str = String(error) + if (str && str !== "[object Object]") return str + const ctor = error.constructor?.name + const prefix = ctor && ctor !== "Object" ? ctor : "Error" + const names = Object.getOwnPropertyNames(error) + return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` + } + return json } catch { return "Unexpected error (unserializable)" } @@ -34,7 +46,7 @@ export function errorMessage(error: unknown): string { if (text && text !== "[object Object]") return text const formatted = errorFormat(error) - if (formatted && formatted !== "{}") return formatted + if (formatted) return formatted return "unknown error" } @@ -45,7 +57,7 @@ export function errorData(error: unknown) { message: errorMessage(error), stack: error.stack, cause: error.cause === undefined ? undefined : errorFormat(error.cause), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -53,7 +65,7 @@ export function errorData(error: unknown) { return { type: typeof error, message: errorMessage(error), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -71,12 +83,6 @@ export function errorData(error: unknown) { if (typeof data.message !== "string") data.message = errorMessage(error) if (typeof data.type !== "string") data.type = error.constructor?.name - data.formatted = errorFormatted(error) + data.formatted = errorFormat(error) return data } - -function errorFormatted(error: unknown) { - const formatted = errorFormat(error) - if (formatted !== "{}") return formatted - return String(error) -} diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 86967e11a0..d9abf18a52 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -14,5 +14,7 @@ export function lazy(fn: () => T) { value = undefined } + result.loaded = () => loaded + return result } diff --git a/packages/opencode/src/util/timeout.ts b/packages/opencode/src/util/timeout.ts index 31ac481468..22f2648c92 100644 --- a/packages/opencode/src/util/timeout.ts +++ b/packages/opencode/src/util/timeout.ts @@ -1,4 +1,4 @@ -export function withTimeout(promise: Promise, ms: number): Promise { +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { let timeout: NodeJS.Timeout return Promise.race([ promise.finally(() => { @@ -6,7 +6,7 @@ export function withTimeout(promise: Promise, ms: number): Promise { }), new Promise((_, reject) => { timeout = setTimeout(() => { - reject(new Error(`Operation timed out after ${ms}ms`)) + reject(new Error(label ?? `Operation timed out after ${ms}ms`)) }, ms) }), ]) diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts new file mode 100644 index 0000000000..1cc443974d --- /dev/null +++ b/packages/opencode/src/v2/auth.ts @@ -0,0 +1,246 @@ +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "@opencode-ai/core/util/identifier" +import { NonNegativeInt, withStatics } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { AppFileSystem } from "@opencode-ai/core/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const AccountID = Schema.String.pipe( + Schema.brand("AccountID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type AccountID = typeof AccountID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "AuthV2.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Account extends Schema.Class("AuthV2.Account")({ + id: AccountID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type AuthError = AuthFileWriteError + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const accountID = AccountID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Account({ + id: accountID, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = accountID + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (accountID: AccountID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + active?: boolean + }) => Effect.Effect + readonly update: ( + accountID: AccountID, + updates: Partial>, + ) => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly activate: (accountID: AccountID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const file = path.join(global.data, "auth-v2.json") + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) + } catch {} + } + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } + + if ("version" in raw && raw.version === 2) return raw as Writable + + const migrated = migrate(raw as Record) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe(yield* load()) + + const result: Interface = { + get: Effect.fn("AuthV2.get")(function* (accountID) { + return (yield* SynchronizedRef.get(state)).accounts[accountID] + }), + + all: Effect.fn("AuthV2.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("AuthV2.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("AuthV2.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("AuthV2.add")(function* (input) { + return yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = new Account({ + id: AccountID.make(Identifier.ascending()), + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: + (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) + ? { ...data.active, [input.serviceID]: account.id } + : data.active, + } + + yield* write(next) + return [account, next] as const + }), + ) + }), + + update: Effect.fn("AuthV2.update")(function* (accountID, updates) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const existing = data.accounts[accountID] + if (!existing) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [accountID]: new Account({ + id: accountID, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("AuthV2.remove")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) + delete active[accounts[accountID].serviceID] + delete accounts[accountID] + + const next = { ...data, accounts, active } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + activate: Effect.fn("AuthV2.activate")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = data.accounts[accountID] + if (!account) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as AuthV2 from "./auth" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts new file mode 100644 index 0000000000..fde8d4326f --- /dev/null +++ b/packages/opencode/src/v2/event.ts @@ -0,0 +1,53 @@ +import { Identifier } from "@/id/id" +import { SyncEvent } from "@/sync" +import { withStatics } from "@/util/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Schema from "effect/Schema" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type + +export function define(input: { + type: Type + schema: Fields + aggregate: string + version?: number +}) { + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + type: Schema.Literal(input.type), + data: Schema.Struct(input.schema), + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: input.aggregate, + schema: Payload.fields.data, + }) + + return Object.assign(Payload, { + Sync, + version: input.version, + aggregate: input.aggregate, + }) +} + +export function run( + def: Def, + data: SyncEvent.Event["data"], + options?: { publish?: boolean }, +) { + if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return + SyncEvent.run(def, data, options) +} + +export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts new file mode 100644 index 0000000000..db66199a59 --- /dev/null +++ b/packages/opencode/src/v2/model.ts @@ -0,0 +1,192 @@ +import { withStatics } from "@/util/schema" +import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" + +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) +export type ID = typeof ID.Type + +export const ProviderID = Schema.String.pipe( + Schema.brand("Model.ProviderID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ProviderID = typeof ProviderID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( + Schema.toTaggedUnion("type"), +) +export type Endpoint = typeof Endpoint.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("Model.Info")({ + id: ID, + providerID: ProviderID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) {} + +export function parse(input: string): { providerID: ProviderID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export interface Interface { + readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> + readonly add: (model: Info) => Effect.Effect + readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (provider: ProviderID) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/v2/Model") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let models = HashMap.empty() + + function key(providerID: ProviderID, modelID: ID) { + return `${providerID}/${modelID}` + } + + const result: Interface = { + get: Effect.fn("V2Model.get")(function* (providerID, modelID) { + return HashMap.get(models, key(providerID, modelID)) + }), + + add: Effect.fn("V2Model.add")(function* (model) { + models = HashMap.set(models, key(model.providerID, model.id), model) + }), + + remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { + models = HashMap.remove(models, key(providerID, modelID)) + }), + + all: Effect.fn("V2Model.all")(function* () { + return pipe( + models, + HashMap.toValues, + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + default: Effect.fn("V2Model.default")(function* () { + const all = yield* result.all() + return Option.fromUndefinedOr(all[0]) + }), + + small: Effect.fn("V2Model.small")(function* (providerID) { + const all = yield* result.all() + const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) + return Option.fromUndefinedOr(match) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer + +export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/schema.ts b/packages/opencode/src/v2/schema.ts new file mode 100644 index 0000000000..44587b838a --- /dev/null +++ b/packages/opencode/src/v2/schema.ts @@ -0,0 +1,10 @@ +import { DateTime, Schema, SchemaGetter } from "effect" + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) + +export * as V2Schema from "./schema" diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts deleted file mode 100644 index 3fe4266c04..0000000000 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionEntry } from "./session-entry" - -export type MemoryState = { - entries: SessionEntry.Entry[] - pending: SessionEntry.Entry[] -} - -export interface Adapter { - readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined - readonly updateAssistant: (assistant: SessionEntry.Assistant) => void - readonly appendEntry: (entry: SessionEntry.Entry) => void - readonly appendPending: (entry: SessionEntry.Entry) => void - readonly finish: () => Result -} - -export function memory(state: MemoryState): Adapter { - const activeAssistantIndex = () => - state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - return { - getCurrentAssistant() { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = state.entries[index] - return assistant?.type === "assistant" ? assistant : undefined - }, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = state.entries[index] - if (current?.type !== "assistant") return - state.entries[index] = assistant - }, - appendEntry(entry) { - state.entries.push(entry) - }, - appendPending(entry) { - state.pending.push(entry) - }, - finish() { - return state - }, - } -} - -export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { - const currentAssistant = adapter.getCurrentAssistant() - type DraftAssistant = WritableDraft - type DraftTool = WritableDraft - type DraftText = WritableDraft - type DraftReasoning = WritableDraft - - const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => - assistant?.content.findLast( - (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), - ) - - const latestText = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftText => item.type === "text") - - const latestReasoning = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") - - SessionEvent.Event.match(event, { - prompt: (event) => { - const entry = SessionEntry.User.fromEvent(event) - if (currentAssistant) { - adapter.appendPending(entry) - return - } - adapter.appendEntry(entry) - }, - synthetic: (event) => { - adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) - }, - "step.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - }), - ) - } - adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) - }, - "step.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - draft.cost = event.cost - draft.tokens = event.tokens - }), - ) - } - }, - "text.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "text", - text: "", - }) - }), - ) - } - }, - "text.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "text.ended": () => {}, - "tool.input.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "tool", - callID: event.callID, - name: event.name, - time: { - created: event.timestamp, - }, - state: { - status: "pending", - input: "", - }, - }) - }), - ) - } - }, - "tool.input.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match && match.state.status === "pending") match.state.input += event.delta - }), - ) - } - }, - "tool.input.ended": () => {}, - "tool.called": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match) { - match.time.ran = event.timestamp - match.state = { - status: "running", - input: event.input, - } - } - }), - ) - } - }, - "tool.success": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: match.state.input, - output: event.output ?? "", - title: event.title, - metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], - } - } - }), - ) - } - }, - "tool.error": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "error", - error: event.error, - input: match.state.input, - metadata: event.metadata ?? {}, - } - } - }), - ) - } - }, - "reasoning.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "reasoning", - text: "", - }) - }), - ) - } - }, - "reasoning.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "reasoning.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text = event.text - }), - ) - } - }, - retried: (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] - }), - ) - } - }, - compacted: (event) => { - adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) - }, - }) - - return adapter.finish() -} - -export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { - return produce(old, (draft) => { - stepWith(memory(draft as MemoryState), event) - }) -} - -export * as SessionEntryStepper from "./session-entry-stepper" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts deleted file mode 100644 index 66576a688e..0000000000 --- a/packages/opencode/src/v2/session-entry.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Schema } from "effect" -import { NonNegativeInt } from "@/util/schema" -import { SessionEvent } from "./session-event" - -export const ID = SessionEvent.ID -export type ID = Schema.Schema.Type - -const Base = { - id: SessionEvent.ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -} - -export class User extends Schema.Class("Session.Entry.User")({ - ...Base, - text: SessionEvent.Prompt.fields.text, - files: SessionEvent.Prompt.fields.files, - agents: SessionEvent.Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Prompt) { - return new User({ - id: event.id, - type: "user", - metadata: event.metadata, - text: event.text, - files: event.files, - agents: event.agents, - time: { created: event.timestamp }, - }) - } -} - -export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ - ...SessionEvent.Synthetic.fields, - ...Base, - type: Schema.Literal("synthetic"), -}) { - static fromEvent(event: SessionEvent.Synthetic) { - return new Synthetic({ - ...event, - time: { created: event.timestamp }, - }) - } -} - -export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - title: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), -}) {} - -export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - error: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ - type: Schema.Literal("tool"), - callID: Schema.String, - name: Schema.String, - state: ToolState, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - ran: Schema.DateTimeUtc.pipe(Schema.optional), - completed: Schema.DateTimeUtc.pipe(Schema.optional), - pruned: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ - type: Schema.Literal("text"), - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - text: Schema.String, -}) {} - -export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: NonNegativeInt, - error: SessionEvent.RetryError, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Retried) { - return new AssistantRetry({ - attempt: event.attempt, - error: event.error, - time: { - created: event.timestamp, - }, - }) - } -} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Entry.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - content: AssistantContent.pipe(Schema.Array), - retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, - cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, - }), - }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - completed: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) { - static fromEvent(event: SessionEvent.Step.Started) { - return new Assistant({ - id: event.id, - type: "assistant", - time: { - created: event.timestamp, - }, - content: [], - retries: [], - }) - } -} - -export class Compaction extends Schema.Class("Session.Entry.Compaction")({ - ...SessionEvent.Compacted.fields, - type: Schema.Literal("compaction"), - ...Base, -}) { - static fromEvent(event: SessionEvent.Compacted) { - return new Compaction({ - ...event, - type: "compaction", - time: { created: event.timestamp }, - }) - } -} - -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) - -export type Entry = Schema.Schema.Type - -export type Type = Entry["type"] - -/* -export interface Interface { - readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry - readonly fromSession: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SessionEntry") {} - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const decodeEntry = Schema.decodeUnknownSync(Entry) - - const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) - - const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { - return Database.use((db) => - db - .select() - .from(SessionEntryTable) - .where(eq(SessionEntryTable.session_id, sessionID)) - .orderBy(SessionEntryTable.id) - .all() - .map((row) => decode(row)), - ) - }) - - return Service.of({ - decode, - fromSession, - }) - }), -) -*/ - -export * as SessionEntry from "./session-entry" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index aaf71c8dcc..7c768bd551 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,128 +1,121 @@ -import { Identifier } from "@/id/id" -import { NonNegativeInt, withStatics } from "@/util/schema" -import * as DateTime from "effect/DateTime" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@/util/schema" +import { EventV2 } from "./event" +import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" +export { FileAttachment } +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./schema" +import { Modelv2 } from "./model" -export namespace SessionEvent { - export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), - ) - export type ID = Schema.Schema.Type - type Stamp = Schema.Schema.Type - type BaseInput = { - id?: ID - metadata?: Record - timestamp?: Stamp - } +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export type Source = Schema.Schema.Type - const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - } +const Base = { + timestamp: V2Schema.DateTimeUtcFromMillis, + sessionID: SessionID, +} - export class Source extends Schema.Class("Session.Event.Source")({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, - }) {} +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), + message: Schema.String, +}).annotate({ + identifier: "Session.Error.Unknown", +}) +export type UnknownError = Schema.Schema.Type - export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), - }) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } - } - - export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), - }) {} - - export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - }) {} - - export class Prompt extends Schema.Class("Session.Event.Prompt")({ +export const AgentSwitched = EventV2.define({ + type: "session.next.agent.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("prompt"), - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - }) { - static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) { - return new Prompt({ - id: input.id ?? ID.create(), - type: "prompt", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - files: input.files, - agents: input.agents, - }) - } - } + agent: Schema.String, + }, +}) +export type AgentSwitched = Schema.Schema.Type - export class Synthetic extends Schema.Class("Session.Event.Synthetic")({ +export const ModelSwitched = EventV2.define({ + type: "session.next.model.switched", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + model: Modelv2.Ref, + }, +}) +export type ModelSwitched = Schema.Schema.Type + +export const Prompted = EventV2.define({ + type: "session.next.prompted", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + prompt: Prompt, + }, +}) +export type Prompted = Schema.Schema.Type + +export const Synthetic = EventV2.define({ + type: "session.next.synthetic", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("synthetic"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Synthetic({ - id: input.id ?? ID.create(), - type: "synthetic", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } + }, +}) +export type Synthetic = Schema.Schema.Type - export namespace Step { - export class Started extends Schema.Class("Session.Event.Step.Started")({ +export namespace Shell { + export const Started = EventV2.define({ + type: "session.next.shell.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.started"), - model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - variant: Schema.String.pipe(Schema.optional), - }), - }) { - static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) { - return new Started({ - id: input.id ?? ID.create(), - type: "step.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - model: input.model, - }) - } - } + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Step.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.shell.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.ended"), - reason: Schema.String, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = Schema.Schema.Type +} + +export namespace Step { + export const Started = EventV2.define({ + type: "session.next.step.started", + aggregate: "sessionID", + schema: { + ...Base, + agent: Schema.String, + model: Modelv2.Ref, + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Started = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.step.ended", + aggregate: "sessionID", + schema: { + ...Base, + finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ input: NonNegativeInt, @@ -133,177 +126,128 @@ export namespace SessionEvent { write: NonNegativeInt, }), }), - }) { - static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "step.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - reason: input.reason, - cost: input.cost, - tokens: input.tokens, - }) - } - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type - export namespace Text { - export class Started extends Schema.Class("Session.Event.Text.Started")({ + export const Failed = EventV2.define({ + type: "session.next.step.failed", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "text.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + error: UnknownError, + }, + }) + export type Failed = Schema.Schema.Type +} - export class Delta extends Schema.Class("Session.Event.Text.Delta")({ +export namespace Text { + export const Started = EventV2.define({ + type: "session.next.text.started", + aggregate: "sessionID", + schema: { + ...Base, + }, + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.text.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "text.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Text.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.text.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "text.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Reasoning { - export class Started extends Schema.Class("Session.Event.Reasoning.Started")({ +export namespace Reasoning { + export const Started = EventV2.define({ + type: "session.next.reasoning.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "reasoning.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } + reasoningID: Schema.String, + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Reasoning.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.reasoning.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.delta"), + reasoningID: Schema.String, delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "reasoning.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Reasoning.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.reasoning.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.ended"), + reasoningID: Schema.String, text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "reasoning.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Tool { - export namespace Input { - export class Started extends Schema.Class("Session.Event.Tool.Input.Started")({ +export namespace Tool { + export namespace Input { + export const Started = EventV2.define({ + type: "session.next.tool.input.started", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, name: Schema.String, - type: Schema.Literal("tool.input.started"), - }) { - static create(input: BaseInput & { callID: string; name: string }) { - return new Started({ - id: input.id ?? ID.create(), - type: "tool.input.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - name: input.name, - }) - } - } + }, + }) + export type Started = Schema.Schema.Type - export class Delta extends Schema.Class("Session.Event.Tool.Input.Delta")({ + export const Delta = EventV2.define({ + type: "session.next.tool.input.delta", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { callID: string; delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "tool.input.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - delta: input.delta, - }) - } - } + }, + }) + export type Delta = Schema.Schema.Type - export class Ended extends Schema.Class("Session.Event.Tool.Input.Ended")({ + export const Ended = EventV2.define({ + type: "session.next.tool.input.ended", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { callID: string; text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "tool.input.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type + } - export class Called extends Schema.Class("Session.Event.Tool.Called")({ + export const Called = EventV2.define({ + type: "session.next.tool.called", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.called"), callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -311,148 +255,153 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - tool: string - input: Record - provider: Called["provider"] - }, - ) { - return new Called({ - id: input.id ?? ID.create(), - type: "tool.called", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - tool: input.tool, - input: input.input, - provider: input.provider, - }) - } - } + }, + }) + export type Called = Schema.Schema.Type - export class Success extends Schema.Class("Session.Event.Tool.Success")({ + export const Progress = EventV2.define({ + type: "session.next.tool.progress", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.success"), callID: Schema.String, - title: Schema.String, - output: Schema.String.pipe(Schema.optional), - attachments: Schema.Array(FileAttachment).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), + }, + }) + export type Progress = Schema.Schema.Type + + export const Success = EventV2.define({ + type: "session.next.tool.success", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - title: string - output?: string - attachments?: FileAttachment[] - provider: Success["provider"] - }, - ) { - return new Success({ - id: input.id ?? ID.create(), - type: "tool.success", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - title: input.title, - output: input.output, - attachments: input.attachments, - provider: input.provider, - }) - } - } + }, + }) + export type Success = Schema.Schema.Type - export class Error extends Schema.Class("Session.Event.Tool.Error")({ + export const Failed = EventV2.define({ + type: "session.next.tool.failed", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.error"), callID: Schema.String, - error: Schema.String, + error: UnknownError, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) { - return new Error({ - id: input.id ?? ID.create(), - type: "tool.error", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - error: input.error, - provider: input.provider, - }) - } - } - } + }, + }) + export type Failed = Schema.Schema.Type +} - export class Retried extends Schema.Class("Session.Event.Retried")({ +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: NonNegativeInt.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export type RetryError = Schema.Schema.Type + +export const Retried = EventV2.define({ + type: "session.next.retried", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("retried"), attempt: NonNegativeInt, error: RetryError, - }) { - static create(input: BaseInput & { attempt: number; error: RetryError }) { - return new Retried({ - id: input.id ?? ID.create(), - type: "retried", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - attempt: input.attempt, - error: input.error, - }) - } - } + }, +}) +export type Retried = Schema.Schema.Type - export class Compacted extends Schema.Class("Session.Event.Compated")({ - ...Base, - type: Schema.Literal("compacted"), - auto: Schema.Boolean, - overflow: Schema.Boolean.pipe(Schema.optional), - }) { - static create(input: BaseInput & { auto: boolean; overflow?: boolean }) { - return new Compacted({ - id: input.id ?? ID.create(), - type: "compacted", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - auto: input.auto, - overflow: input.overflow, - }) - } - } - - export const Event = Schema.Union( - [ - Prompt, - Synthetic, - Step.Started, - Step.Ended, - Text.Started, - Text.Delta, - Text.Ended, - Tool.Input.Started, - Tool.Input.Delta, - Tool.Input.Ended, - Tool.Called, - Tool.Success, - Tool.Error, - Reasoning.Started, - Reasoning.Delta, - Reasoning.Ended, - Retried, - Compacted, - ], - { - mode: "oneOf", +export namespace Compaction { + export const Started = EventV2.define({ + type: "session.next.compaction.started", + aggregate: "sessionID", + schema: { + ...Base, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), }, - ).pipe(Schema.toTaggedUnion("type")) - export type Event = Schema.Schema.Type - export type Type = Event["type"] + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.compaction.delta", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + }, + }) + + export const Ended = EventV2.define({ + type: "session.next.compaction.ended", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + include: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type } + +export const All = Schema.Union( + [ + AgentSwitched, + ModelSwitched, + Prompted, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Step.Failed, + Text.Started, + Text.Delta, + Text.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Failed, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + ], + { + mode: "oneOf", + }, +).pipe(Schema.toTaggedUnion("type")) + +// user +// assistant +// assistant +// assistant +// user +// compaction marker +// -> text +// assistant + +export type Event = Schema.Schema.Type +export type Type = Event["type"] + +export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts new file mode 100644 index 0000000000..80ecb1011e --- /dev/null +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -0,0 +1,416 @@ +import { produce, type WritableDraft } from "immer" +import { SessionEvent } from "./session-event" +import { SessionMessage } from "./session-message" + +export type MemoryState = { + messages: SessionMessage.Message[] +} + +export interface Adapter { + readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined + readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined + readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined + readonly updateAssistant: (assistant: SessionMessage.Assistant) => void + readonly updateCompaction: (compaction: SessionMessage.Compaction) => void + readonly updateShell: (shell: SessionMessage.Shell) => void + readonly appendMessage: (message: SessionMessage.Message) => void + readonly finish: () => Result +} + +export function memory(state: MemoryState): Adapter { + const activeAssistantIndex = () => + state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction") + const activeShellIndex = (callID: string) => + state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + + return { + getCurrentAssistant() { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = state.messages[index] + return assistant?.type === "assistant" ? assistant : undefined + }, + getCurrentCompaction() { + const index = activeCompactionIndex() + if (index < 0) return + const compaction = state.messages[index] + return compaction?.type === "compaction" ? compaction : undefined + }, + getCurrentShell(callID) { + const index = activeShellIndex(callID) + if (index < 0) return + const shell = state.messages[index] + return shell?.type === "shell" ? shell : undefined + }, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "assistant") return + state.messages[index] = assistant + }, + updateCompaction(compaction) { + const index = activeCompactionIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "compaction") return + state.messages[index] = compaction + }, + updateShell(shell) { + const index = activeShellIndex(shell.callID) + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "shell") return + state.messages[index] = shell + }, + appendMessage(message) { + state.messages.push(message) + }, + finish() { + return state + }, + } +} + +export function update(adapter: Adapter, event: SessionEvent.Event): Result { + const currentAssistant = adapter.getCurrentAssistant() + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft + + const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => + assistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) + + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") + + const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) + + SessionEvent.All.match(event, { + "session.next.agent.switched": (event) => { + adapter.appendMessage( + new SessionMessage.AgentSwitched({ + id: event.id, + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.model.switched": (event) => { + adapter.appendMessage( + new SessionMessage.ModelSwitched({ + id: event.id, + type: "model-switched", + metadata: event.metadata, + model: event.data.model, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.prompted": (event) => { + adapter.appendMessage( + new SessionMessage.User({ + id: event.id, + type: "user", + metadata: event.metadata, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.synthetic": (event) => { + adapter.appendMessage( + new SessionMessage.Synthetic({ + sessionID: event.data.sessionID, + text: event.data.text, + id: event.id, + type: "synthetic", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.started": (event) => { + adapter.appendMessage( + new SessionMessage.Shell({ + id: event.id, + type: "shell", + metadata: event.metadata, + callID: event.data.callID, + command: event.data.command, + output: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.ended": (event) => { + const currentShell = adapter.getCurrentShell(event.data.callID) + if (currentShell) { + adapter.updateShell( + produce(currentShell, (draft) => { + draft.output = event.data.output + draft.time.completed = event.data.timestamp + }), + ) + } + }, + "session.next.step.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), + ) + } + adapter.appendMessage( + new SessionMessage.Assistant({ + id: event.id, + type: "assistant", + agent: event.data.agent, + model: event.data.model, + time: { created: event.data.timestamp }, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + }), + ) + }, + "session.next.step.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = event.data.finish + draft.cost = event.data.cost + draft.tokens = event.data.tokens + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + }), + ) + } + }, + "session.next.step.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = "error" + draft.error = event.data.error + }), + ) + } + }, + "session.next.text.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "text", + text: "", + }) + }), + ) + } + }, + "session.next.text.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.text.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.tool.input.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { + created: event.data.timestamp, + }, + state: { + status: "pending", + input: "", + }, + }) + }), + ) + } + }, + "session.next.tool.input.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) + if (match && match.state.status === "pending") match.state.input += event.data.delta + }), + ) + } + }, + "session.next.tool.input.ended": () => {}, + "session.next.tool.called": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match) { + match.provider = event.data.provider + match.time.ran = event.data.timestamp + match.state = { + status: "running", + input: event.data.input, + structured: {}, + content: [], + } + } + }), + ) + } + }, + "session.next.tool.progress": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.state.structured = event.data.structured + match.state.content = [...event.data.content] + } + }), + ) + } + }, + "session.next.tool.success": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "completed", + input: match.state.input, + structured: event.data.structured, + content: [...event.data.content], + } + } + }), + ) + } + }, + "session.next.tool.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "error", + error: event.data.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + } + }), + ) + } + }, + "session.next.reasoning.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) + }), + ) + } + }, + "session.next.reasoning.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.reasoning.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.retried": () => {}, + "session.next.compaction.started": (event) => { + adapter.appendMessage( + new SessionMessage.Compaction({ + id: event.id, + type: "compaction", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.compaction.delta": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary += event.data.text + }), + ) + } + }, + "session.next.compaction.ended": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary = event.data.text + draft.include = event.data.include + }), + ) + } + }, + }) + + return adapter.finish() +} + +export * as SessionMessageUpdater from "./session-message-updater" diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts new file mode 100644 index 0000000000..024e28c450 --- /dev/null +++ b/packages/opencode/src/v2/session-message.ts @@ -0,0 +1,172 @@ +import { Schema } from "effect" +import { Prompt } from "./session-prompt" +import { SessionEvent } from "./session-event" +import { EventV2 } from "./event" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./schema" +import { Modelv2 } from "./model" + +export const ID = EventV2.ID +export type ID = Schema.Schema.Type + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +} + +export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: SessionEvent.AgentSwitched.fields.data.fields.agent, +}) {} + +export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ + ...Base, + type: Schema.Literal("model-switched"), + model: Modelv2.Ref, +}) {} + +export class User extends Schema.Class("Session.Message.User")({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +}) {} + +export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ + ...Base, + sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, + text: SessionEvent.Synthetic.fields.data.fields.text, + type: Schema.Literal("synthetic"), +}) {} + +export class Shell extends Schema.Class("Session.Message.Shell")({ + ...Base, + type: Schema.Literal("shell"), + callID: SessionEvent.Shell.Started.fields.data.fields.callID, + command: SessionEvent.Shell.Started.fields.data.fields.command, + output: Schema.String, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ + status: Schema.Literal("pending"), + input: Schema.String, +}) {} + +export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: ToolOutput.Structured, + content: ToolOutput.Content.pipe(Schema.Array), +}) {} + +export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, +}) {} + +export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, + error: SessionEvent.UnknownError, +}) {} + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = Schema.Schema.Type + +export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), + state: ToolState, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, +}) {} + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = Schema.Schema.Type + +export class Assistant extends Schema.Class("Session.Message.Assistant")({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: SessionEvent.Step.Started.fields.data.fields.model, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(Schema.optional), + end: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + finish: Schema.String.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }).pipe(Schema.optional), + error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class Compaction extends Schema.Class("Session.Message.Compaction")({ + type: Schema.Literal("compaction"), + reason: SessionEvent.Compaction.Started.fields.data.fields.reason, + summary: Schema.String, + include: Schema.String.pipe(Schema.optional), + ...Base, +}) {} + +export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) + +export type Message = Schema.Schema.Type + +export type Type = Message["type"] + +export * as SessionMessage from "./session-message" diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts new file mode 100644 index 0000000000..86d8e52eb7 --- /dev/null +++ b/packages/opencode/src/v2/session-prompt.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema" + +export class Source extends Schema.Class("Prompt.Source")({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) + } +} + +export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class("Prompt")({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}) {} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 2bac11f4fe..bb86f039b2 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,69 +1,316 @@ -import { Context, Layer, Schema, Effect } from "effect" -import { SessionEntry } from "./session-entry" -import { Struct } from "effect" -import { Session } from "@/session/session" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { WorkspaceID } from "@/control-plane/schema" +import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" +import * as Database from "@/storage/db" +import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" +import { SessionMessage } from "./session-message" +import type { Prompt } from "./session-prompt" +import { EventV2 } from "./event" +import { ProjectID } from "@/project/schema" +import { SessionEvent } from "./session-event" +import { V2Schema } from "./schema" +import { optionalOmitUndefined } from "@/util/schema" +import { Modelv2 } from "./model" -export const ID = SessionID +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type -export type ID = Schema.Schema.Type - -export class PromptInput extends Schema.Class("Session.PromptInput")({ - ...Struct.omit(SessionEntry.User.fields, ["time", "type"]), - id: Schema.optionalKey(SessionEntry.ID), - sessionID: ID, -}) {} - -export class CreateInput extends Schema.Class("Session.CreateInput")({ - id: Schema.optionalKey(ID), -}) {} +export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ - id: ID, - model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - modelID: Schema.String, - }).pipe(Schema.optional), + id: SessionID, + parentID: optionalOmitUndefined(SessionID), + projectID: ProjectID, + workspaceID: optionalOmitUndefined(WorkspaceID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), + model: Modelv2.Ref.pipe(optionalOmitUndefined), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), + }), + title: Schema.String, + /* + slug: Schema.String, + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), + */ +}) {} + +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: SessionID, }) {} export interface Interface { - fromID: (id: ID) => Effect.Effect - create: (input: CreateInput) => Effect.Effect - prompt: (input: PromptInput) => Effect.Effect + readonly create: (input?: { + agent?: string + model?: Modelv2.Ref + parentID?: SessionID + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly get: (sessionID: SessionID) => Effect.Effect + readonly list: (input: { + limit?: number + order?: "asc" | "desc" + directory?: string + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + cursor?: { + id: SessionID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: SessionID + prompt: Prompt + delivery?: Delivery + }) => Effect.Effect + readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect + readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly subagent: (input: { + id?: EventV2.ID + parentID: SessionID + prompt: Prompt + agent: string + model?: Modelv2.Ref + }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect + readonly compact: (sessionID: SessionID) => Effect.Effect + readonly wait: (sessionID: SessionID) => Effect.Effect } -export class Service extends Context.Service()("Session.Service") {} +export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect(Service)( +export const layer = Layer.effect( + Service, Effect.gen(function* () { - const session = yield* Session.Service + const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) - const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) { - throw new Error("Not implemented") - }) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }) - const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) { - throw new Error("Not implemented") - }) + function fromRow(row: typeof SessionTable.$inferSelect): Info { + return new Info({ + id: SessionID.make(row.id), + projectID: ProjectID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: Modelv2.ID.make(row.model.id), + providerID: Modelv2.ProviderID.make(row.model.providerID), + variant: Modelv2.VariantID.make(row.model.variant ?? "default"), + } + : undefined, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + }) + } - const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) { - const match = yield* session.get(id) - return fromV1(match) - }) + const result: Interface = { + create: Effect.fn("V2Session.create")(function* (_input) { + return {} as any + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), + list: Effect.fn("V2Session.list")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const conditions: SQL[] = [] + if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.path) + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.start) conditions.push(gte(SessionTable.time_created, input.start)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or( + gt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + )! + : or( + lt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + )!, + ) + } + const query = Database.Client() + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) - return Service.of({ - create, - prompt, - fromID, - }) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + gt(SessionMessageTable.id, input.cursor.id), + ), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + lt(SessionMessageTable.id, input.cursor.id), + ), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + + const rows = Database.use((db) => { + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return direction === "previous" ? rows.toReversed() : rows + }) + return rows.map((row) => decode(row)) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + const rows = Database.use((db) => { + const compaction = db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + + return db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and( + eq(SessionMessageTable.time_created, compaction.time_created), + gte(SessionMessageTable.id, compaction.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + }) + return rows.map((row) => decode(row)) + }), + prompt: Effect.fn("V2Session.prompt")(function* (_input) { + return {} as any + }), + shell: Effect.fn("V2Session.shell")(function* (_input) {}), + skill: Effect.fn("V2Session.skill")(function* (_input) {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + model: input.model, + }) + }), + subagent: Effect.fn("V2Session.subagent")(function* (input) { + const parent = yield* result.get(input.parentID) + const session = yield* result.create({ + agent: input.agent, + model: input.model, + parentID: input.parentID, + workspaceID: parent.workspaceID, + }) + yield* result.prompt({ + prompt: input.prompt, + sessionID: session.id, + }) + yield* Effect.gen(function* () { + yield* result.wait(session.id) + const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + const assistant = messages.find((msg) => msg.type === "assistant") + if (!assistant) return + const text = assistant.content.findLast((part) => part.type === "text") + if (!text) return + }).pipe(Effect.forkChild()) + }), + compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), + wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + } + + return Service.of(result) }), ) -function fromV1(input: Session.Info): Info { - return new Info({ - id: ID.make(input.id), - }) -} +export const defaultLayer = layer export * as SessionV2 from "./session" diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/opencode/src/v2/tool-output.ts new file mode 100644 index 0000000000..dee2bb11ed --- /dev/null +++ b/packages/opencode/src/v2/tool-output.ts @@ -0,0 +1,18 @@ +export * as ToolOutput from "./tool-output" +import { Schema } from "effect" + +export class TextContent extends Schema.Class("Tool.TextContent")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class FileContent extends Schema.Class("Tool.FileContent")({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), +}) {} + +export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type")) + +export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6a7ccb9614..f4e4d2721c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,8 +1,8 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" +import { InstanceLayer } from "@/project/instance-layer" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -160,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service + | AppFileSystem.Service + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Git.Service + | Project.Service + | InstanceStore.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -170,6 +175,7 @@ export const layer: Layer.Layer< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const gitSvc = yield* Git.Service const project = yield* Project.Service + const store = yield* InstanceStore.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -252,14 +258,10 @@ export const layer: Layer.Layer< return } - const booted = yield* Effect.promise(() => - Instance.provide({ - directory: info.directory, - init: () => BootstrapRuntime.runPromise(InstanceBootstrap), - fn: () => undefined, - }) - .then(() => true) - .catch((error) => { + const booted = yield* store.load({ directory: info.directory }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.sync(() => { const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { @@ -270,6 +272,7 @@ export const layer: Layer.Layer< }) return false }), + ), ) if (!booted) return @@ -288,16 +291,15 @@ export const layer: Layer.Layer< const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { yield* setup(info) - yield* boot(info, startCommand) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) + yield* createFromInfo(info, input?.startCommand) return info }) @@ -581,7 +583,7 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( +export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), @@ -589,4 +591,6 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) + export * as Worktree from "." diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 00564a17bf..41372b15a0 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s ```typescript import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) describe("my service", () => { - it.live("does the thing", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const svc = yield* MyService.Service - const out = yield* svc.run() - expect(out).toEqual("ok") - }), - ), + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), ) }) ``` @@ -111,6 +108,7 @@ describe("my service", () => { - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context. - Most integration-style tests in this package use `it.live(...)`. ### Effect Fixtures @@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. -Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. +Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path: + +```typescript +import { TestInstance } from "../fixture/fixture" + +it.instance("uses the temp directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) +``` + +Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime. ### Style @@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst - Keep the test body inside `Effect.gen(function* () { ... })`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. -- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. +- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index bce5e94598..2722757ab9 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" type SessionUpdateParams = Parameters[0] @@ -58,6 +59,7 @@ function toolEvent( raw: opts.raw, } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, @@ -262,7 +264,7 @@ function createFakeAgent() { describe("acp.agent event subscription", () => { test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, updates, stop } = createFakeAgent() @@ -297,7 +299,7 @@ describe("acp.agent event subscription", () => { test("does not emit user_message_chunk for live prompt parts", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -337,7 +339,7 @@ describe("acp.agent event subscription", () => { test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, chunks, stop } = createFakeAgent() @@ -389,7 +391,7 @@ describe("acp.agent event subscription", () => { test("does not create additional event subscriptions on repeated loadSession()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, calls, stop } = createFakeAgent() @@ -411,7 +413,7 @@ describe("acp.agent event subscription", () => { test("permission.asked events are handled and replied", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -450,7 +452,7 @@ describe("acp.agent event subscription", () => { test("permission prompt on session A does not block message updates for session B", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -537,7 +539,7 @@ describe("acp.agent event subscription", () => { test("streams running bash output snapshots and de-dupes identical snapshots", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -571,7 +573,7 @@ describe("acp.agent event subscription", () => { test("emits synthetic pending before first running update for any tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -616,7 +618,7 @@ describe("acp.agent event subscription", () => { test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() @@ -675,7 +677,7 @@ describe("acp.agent event subscription", () => { test("clears bash snapshot marker on pending state", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index ec384709da..6996e54b47 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,10 +1,12 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" +import { Global } from "@opencode-ai/core/global" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -17,12 +19,12 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("returns default native agents when no config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await load(tmp.path, (svc) => svc.list()) @@ -40,7 +42,7 @@ test("returns default native agents when no config", async () => { test("build agent has correct default properties", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -55,7 +57,7 @@ test("build agent has correct default properties", async () => { test("plan agent denies edits except .opencode/plans/*", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const plan = await load(tmp.path, (svc) => svc.get("plan")) @@ -70,7 +72,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -83,23 +85,26 @@ test("explore agent denies edit and write", async () => { }) }) -test("explore agent asks for external directories and allows Truncate.GLOB", async () => { +test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") }, }) }) test("general agent denies todo tools", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const general = await load(tmp.path, (svc) => svc.get("general")) @@ -113,7 +118,7 @@ test("general agent denies todo tools", async () => { test("compaction agent denies all permissions", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const compaction = await load(tmp.path, (svc) => svc.get("compaction")) @@ -139,7 +144,7 @@ test("custom agent from config creates new agent", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) @@ -168,7 +173,7 @@ test("custom agent config overrides native agent properties", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -191,7 +196,7 @@ test("agent disable removes agent from list", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -217,7 +222,7 @@ test("agent permission config merges with defaults", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -238,7 +243,7 @@ test("global permission config applies to all agents", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -257,7 +262,7 @@ test("agent steps/maxSteps config sets steps property", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -276,7 +281,7 @@ test("agent mode can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -293,7 +298,7 @@ test("agent name can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -310,7 +315,7 @@ test("agent prompt can be set from config", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -330,7 +335,7 @@ test("unknown agent properties are placed into options", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -353,7 +358,7 @@ test("agent options merge correctly", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -378,7 +383,7 @@ test("multiple custom agents can be defined", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) @@ -407,7 +412,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) @@ -419,7 +424,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn test("Agent.get returns undefined for non-existent agent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) @@ -430,7 +435,7 @@ test("Agent.get returns undefined for non-existent agent", async () => { test("default permission includes doom_loop and external_directory as ask", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -442,7 +447,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn test("webfetch is allowed by default", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -464,7 +469,7 @@ test("legacy tools config converts to permissions", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -486,7 +491,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -504,7 +509,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -515,6 +520,20 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) }) +test("global tmp directory children are allowed for external_directory", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const build = await load(tmp.path, (svc) => svc.get("build")) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }, + }) +}) + test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ @@ -528,7 +547,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -551,7 +570,7 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -583,7 +602,7 @@ description: Permission skill. process.env.OPENCODE_TEST_HOME = tmp.path try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -599,7 +618,7 @@ description: Permission skill. test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -614,7 +633,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { default_agent: "plan", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -634,7 +653,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -649,7 +668,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { default_agent: "explore", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') @@ -663,7 +682,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = default_agent: "compaction", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') @@ -677,7 +696,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn default_agent: "does_not_exist", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( @@ -695,7 +714,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -714,7 +733,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // build and plan are disabled, no primary-capable agents remain diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts new file mode 100644 index 0000000000..dff972d100 --- /dev/null +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -0,0 +1,28 @@ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import { Agent } from "../../src/agent/agent" +import { Plugin } from "../../src/plugin" +import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" + +// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin +// up — those services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href + +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) + +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.Service.use((svc) => svc.list()) + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, +) diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 0daf8fe6a6..377c541096 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -2,9 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node) const it = testEffect(live) describe("Bus (Effect-native)", () => { - it.live("publish + subscribe stream delivers events", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() + it.instance("publish + subscribe stream delivers events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Deferred.await(done) - expect(received).toEqual([1, 2]) - }), - ), + expect(received).toEqual([1, 2]) + }), ) - it.live("subscribe filters by event type", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() + it.instance("subscribe filters by event type", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Pong, { message: "ignored" }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done) - expect(pings).toEqual([42]) - }), - ), + expect(pings).toEqual([42]) + }), ) - it.live("subscribeAll receives all types", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() + it.instance("subscribeAll receives all types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const types: string[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribeAll(), (evt) => - Effect.sync(() => { - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribeAll(), (evt) => + Effect.sync(() => { + types.push(evt.type) + if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done) - expect(types).toContain("test.effect.ping") - expect(types).toContain("test.effect.pong") - }), - ), + expect(types).toContain("test.effect.ping") + expect(types).toContain("test.effect.pong") + }), ) - it.live("multiple subscribers each receive the event", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() + it.instance("multiple subscribers each receive the event", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(doneA) + yield* Deferred.await(doneB) - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ), + expect(a).toEqual([99]) + expect(b).toEqual([99]) + }), ) it.live("subscribeAll stream sees InstanceDisposed on disposal", () => @@ -151,7 +142,7 @@ describe("Bus (Effect-native)", () => { }).pipe(provideInstance(dir)) // Dispose from OUTSIDE the instance scope - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) expect(types).toContain("test.effect.ping") diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 2808344577..3e3d7a3e90 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,16 +3,17 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus integration: acquireRelease subscriber pattern", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) test("subscriber via callback facade receives events and cleans up on unsub", async () => { await using tmp = await tmpdir() @@ -78,7 +79,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => { await Bun.sleep(10) }) - await Instance.disposeAll() + await disposeAllInstances() await Bun.sleep(50) expect(received).toEqual([1]) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index cdacdd5179..876cb1ed74 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,7 +3,8 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), @@ -11,11 +12,11 @@ const TestEvent = { } function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("publish + subscribe", () => { test("subscriber is live immediately after subscribe returns", async () => { @@ -208,8 +209,8 @@ describe("Bus", () => { await Bun.sleep(10) }) - // Instance.disposeAll triggers the finalizer which publishes InstanceDisposed - await Instance.disposeAll() + // disposeAllInstances triggers the finalizer which publishes InstanceDisposed + await disposeAllInstances() await Bun.sleep(50) expect(received).toContain("test.ping") diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts new file mode 100644 index 0000000000..34a16aedd6 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/prompt/traits" + +describe("computePromptTraits", () => { + test("normal mode without autocomplete only captures tab", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toEqual(["tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("normal mode with autocomplete captures navigation keys", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("shell mode does not suspend the textarea", () => { + // Suspending the textarea would gate every keybinding action + // (backspace, delete-word-backward, arrow movement, etc.) — see + // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is + // an active editing mode, so suspend must stay off. + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.suspend).toBe(false) + }) + + test("shell mode disables capture and labels the prompt", () => { + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toBeUndefined() + expect(traits.status).toBe("SHELL") + }) + + test("disabled suspends regardless of mode", () => { + expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + }) +}) diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 0000000000..de6fed8daa --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +// Regression for PR #25522: when an effectCmd handler does +// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, +// the inner runPromise creates a fresh fiber after `await` whose Effect context +// has lost the outer InstanceRef. Services that read `InstanceState.context` +// then fall back to `Instance.current` ALS, which must be installed at the JS +// callback boundary (Node ALS persists across awaits, Effect's fiber context +// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// Pins effect-cmd.ts directly: the pattern test below exercises the load + +// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't +// fail it. This grep guards the actual production callsite. +test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { + const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") + expect(source).toContain("Instance.restore(ctx") +}) + +test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { + await using dir = await tmpdir({ git: true }) + await provideTestInstance({ + directory: dir.path, + fn: () => + Effect.runPromise( + Effect.promise(async () => { + await new Promise((r) => setTimeout(r, 5)) + const current = await Effect.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + ), + }) +}) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 5b0fcad3c9..78253361b7 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string }) function vcs(branch: string): Event { return { + id: `evt_vcs_${branch}`, type: "vcs.branch.updated", properties: { branch, @@ -34,6 +35,7 @@ function vcs(branch: string): Event { function update(version: string): Event { return { + id: `evt_update_${version}`, type: "installation.update-available", properties: { version, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c3ae249e57..0a522b0850 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -7,13 +7,15 @@ import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { provideTmpdirInstance } from "../fixture/fixture" +import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -41,6 +43,12 @@ const emptyAuth = Layer.mock(Auth.Service)({ const testFlock = EffectFlock.defaultLayer +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + which: () => Effect.succeed(Option.none()), +}) + const layer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -48,7 +56,7 @@ const layer = Config.layer.pipe( Layer.provide(emptyAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) const it = testEffect(layer) @@ -57,9 +65,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe const save = (config: Config.Info) => Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) const saveGlobal = (config: Config.Info) => - Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer))) -const clear = (wait = false) => - Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) + Effect.runPromise( + Config.Service.use((svc) => svc.updateGlobal(config)).pipe( + Effect.map((result) => result.info), + Effect.scoped, + Effect.provide(layer), + ), + ) +const clear = async (wait = false) => { + await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) + if (wait) await InstanceRuntime.disposeAllInstances() +} const listDirs = () => Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) const ready = () => @@ -98,7 +114,7 @@ async function check(map: (dir: string) => string) { $schema: "https://opencode.ai/config.json", snapshot: false, }) - await Instance.provide({ + await WithInstance.provide({ directory: map(tmp.path), fn: async () => { const cfg = await load() @@ -108,7 +124,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await Instance.disposeAll() + await InstanceRuntime.disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } @@ -116,7 +132,7 @@ async function check(map: (dir: string) => string) { test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -135,7 +151,7 @@ test("loads JSON config file", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -154,7 +170,7 @@ test("loads shell config field", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -176,7 +192,7 @@ test("updates config and preserves empty shell sentinel", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await save({ shell: "" }) @@ -254,7 +270,7 @@ test("loads formatter boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -272,7 +288,7 @@ test("loads lsp boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -309,7 +325,7 @@ test("ignores legacy tui keys in opencode config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -334,7 +350,7 @@ test("loads JSONC config file", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -362,7 +378,7 @@ test("jsonc overrides json in the same directory", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -385,7 +401,7 @@ test("handles environment variable substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -417,7 +433,7 @@ test("preserves env variables when adding $schema to config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -483,6 +499,7 @@ test("resolves env templates in account config with account token", async () => Layer.provide(emptyAuth), Layer.provide(fakeAccount), Layer.provideMerge(infra), + Layer.provide(noopNpm), ) try { @@ -493,7 +510,7 @@ test("resolves env templates in account config with account token", async () => expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") }), ), - ).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise) + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { if (originalControlToken !== undefined) { process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken @@ -513,7 +530,7 @@ test("handles file inclusion substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -532,7 +549,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -550,7 +567,7 @@ test("validates config schema and throws on invalid fields", async () => { }) }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { // Strict schema should throw an error for invalid fields @@ -565,7 +582,7 @@ test("throws error for invalid JSON", async () => { await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { await expect(load()).rejects.toThrow() @@ -588,7 +605,7 @@ test("handles agent configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -619,7 +636,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -649,7 +666,7 @@ test("handles command configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -674,7 +691,7 @@ test("migrates autoshare to share field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -701,7 +718,7 @@ test("migrates mode field to agent field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -733,7 +750,7 @@ Test agent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -766,7 +783,7 @@ Ordered permissions`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -804,7 +821,7 @@ Nested agent prompt`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -853,7 +870,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -898,7 +915,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -918,7 +935,7 @@ Nested command template`, test("updates config and writes to file", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } @@ -932,7 +949,7 @@ test("updates config and writes to file", async () => { test("gets config directories", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const dirs = await listDirs() @@ -962,7 +979,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as process.env.OPENCODE_CONFIG_DIR = tmp.extra try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await load() @@ -986,11 +1003,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const prev = process.env.OPENCODE_CONFIG_DIR process.env.OPENCODE_CONFIG_DIR = tmp.extra - const noopNpm = Layer.mock(Npm.Service)({ - install: () => Effect.void, - add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), - }) const testLayer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -1002,7 +1014,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { ) try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) @@ -1061,7 +1073,7 @@ test("resolves scoped npm plugins in config", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() @@ -1099,7 +1111,7 @@ test("merges plugin arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1135,7 +1147,7 @@ Helper subagent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1174,7 +1186,7 @@ test("merges instructions arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1213,7 +1225,7 @@ test("deduplicates duplicate instructions from global and local configs", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1258,7 +1270,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1307,7 +1319,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const cfg = await load() @@ -1348,7 +1360,7 @@ test("migrates legacy tools config to permissions - allow", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1379,7 +1391,7 @@ test("migrates legacy tools config to permissions - deny", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1409,7 +1421,7 @@ test("migrates legacy write tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1441,7 +1453,7 @@ test("managed settings override user settings", async () => { share: "disabled", }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1469,7 +1481,7 @@ test("managed settings override project settings", async () => { disabled_providers: ["openai"], }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1489,7 +1501,7 @@ test("missing managed settings file is not an error", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1516,7 +1528,7 @@ test("migrates legacy edit tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1545,7 +1557,7 @@ test("migrates legacy patch tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1577,7 +1589,7 @@ test("migrates mixed legacy tools config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1612,7 +1624,7 @@ test("merges legacy tools with existing permission config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1649,7 +1661,7 @@ test("permission config preserves user key order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1732,7 +1744,7 @@ test("project config can override MCP server enabled status", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1788,7 +1800,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1839,7 +1851,7 @@ test("local .opencode config can override MCP from project config", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1883,7 +1895,7 @@ test("project config overrides remote well-known config", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -1941,7 +1953,7 @@ test("wellknown URL with trailing slash is normalized", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -2096,7 +2108,7 @@ describe("deduplicatePluginOrigins", () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -2128,7 +2140,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2159,7 +2171,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const directories = await listDirs() @@ -2183,7 +2195,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) @@ -2225,7 +2237,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // The relative instruction should be skipped without error @@ -2285,7 +2297,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path - await Instance.provide({ + await WithInstance.provide({ directory: projectTmp.path, fn: async () => { const config = await load() @@ -2320,7 +2332,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2354,7 +2366,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 46a3f06263..5053a7e1f7 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" @@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" import { ConfigPlugin } from "@/config/plugin" const wintest = process.platform === "win32" ? test : test.skip -const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) +const clear = async (wait = false) => { + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate())) + if (wait) await InstanceRuntime.disposeAllInstances() +} const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) beforeEach(async () => { @@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const server = await load() @@ -624,3 +627,43 @@ test("merges plugin_enabled flags across config layers", async () => { "local.plugin": true, }) }) + +test("silently skips malformed tui.json — load failures degrade to {}", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",') + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Project tui.json is malformed → silently skipped (logs a warning) + // .opencode/tui.json (lower precedence in this path) still loads + expect(config.theme).toBe("fallback") +}) + +test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // tui.json exists as a DIRECTORY rather than a file → readFileString fails + // with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes + // that through catchCause → log + skip, so a fallback layer should still load. + await fs.mkdir(path.join(dir, "tui.json"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Did NOT crash; .opencode/tui.json (lower precedence) still loads. + expect(config.theme).toBe("fallback") +}) + +test("missing tui.json — silently treated as empty (ENOENT path)", async () => { + await using tmp = await tmpdir({}) + + // No tui.json anywhere. Should not throw. + const config = await getTuiConfig(tmp.path) + expect(config).toBeDefined() + // No theme set anywhere. + expect(config.theme).toBeUndefined() +}) diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adapters.test.ts similarity index 68% rename from packages/opencode/test/control-plane/adaptors.test.ts rename to packages/opencode/test/control-plane/adapters.test.ts index a8e490226b..762bb5d57e 100644 --- a/packages/opencode/test/control-plane/adaptors.test.ts +++ b/packages/opencode/test/control-plane/adapters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors" +import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" import { ProjectID } from "../../src/project/schema" import type { WorkspaceInfo } from "../../src/control-plane/types" @@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf } } -function adaptor(dir: string) { +function adapter(dir: string) { return { name: dir, description: dir, @@ -33,19 +33,19 @@ function adaptor(dir: string) { } } -describe("control-plane/adaptors", () => { - test("isolates custom adaptors by project", async () => { +describe("control-plane/adapters", () => { + test("isolates custom adapters by project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(one, type, adaptor("/one")) - registerAdaptor(two, type, adaptor("/two")) + registerAdapter(one, type, adapter("/one")) + registerAdapter(two, type, adapter("/two")) - expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({ + expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({ type: "local", directory: "/one", }) - expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({ + expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({ type: "local", directory: "/two", }) @@ -54,16 +54,16 @@ describe("control-plane/adaptors", () => { test("latest install wins within a project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(id, type, adaptor("/one")) + registerAdapter(id, type, adapter("/one")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/one", }) - registerAdaptor(id, type, adaptor("/two")) + registerAdapter(id, type, adapter("/two")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/two", }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 594789b207..84f5670064 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { asc, eq } from "drizzle-orm" +import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" @@ -14,19 +14,19 @@ import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" -import { SessionID, MessageID, PartID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" -import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { registerAdaptor } from "../../src/control-plane/adaptors" +import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" -import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types" +import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as WorkspaceOld from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" @@ -53,8 +53,8 @@ type RecordedCreate = { from?: WorkspaceInfo } -type RecordedAdaptor = { - adaptor: WorkspaceAdaptor +type RecordedAdapter = { + adapter: WorkspaceAdapter calls: { configure: WorkspaceInfo[] create: RecordedCreate[] @@ -93,7 +93,7 @@ beforeEach(() => { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag restoreEnv() await resetDatabase() @@ -101,7 +101,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return Instance.provide({ + return WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) @@ -110,8 +110,8 @@ async function withInstance(fn: (dir: string) => T | Promise) { const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) +const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) const listWorkspaces = (project: Parameters[0]) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) @@ -165,13 +165,13 @@ function eventuallyEffect(effect: Effect.Effect, timeout = 1500) { }) } -function recordedAdaptor(input: { +function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise remove?: (info: WorkspaceInfo) => Promise -}): RecordedAdaptor { - const calls: RecordedAdaptor["calls"] = { +}): RecordedAdapter { + const calls: RecordedAdapter["calls"] = { configure: [], create: [], remove: [], @@ -180,7 +180,7 @@ function recordedAdaptor(input: { return { calls, - adaptor: { + adapter: { name: "recorded", description: "recorded", configure(info) { @@ -207,8 +207,8 @@ function recordedAdaptor(input: { } } -function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { - return recordedAdaptor({ +function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { + return recordedAdapter({ configure(info) { return { ...info, directory: dir } }, @@ -223,8 +223,8 @@ function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info }) } -function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { - return recordedAdaptor({ +function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { + return recordedAdapter({ configure(info) { return { ...info, directory: input?.directory ?? info.directory } }, @@ -316,48 +316,24 @@ function sessionSequence(sessionID: SessionID) { )?.seq } -function eventRows(sessionID: SessionID) { +function sessionSequenceOwner(sessionID: SessionID) { return Database.use((db) => db - .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), + )?.ownerID } function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -function replaceSessionEvents(sessionID: SessionID, count: number) { - Database.use((db) => { - db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run() - if (count === 0) return - - db.insert(EventSequenceTable) - .values({ aggregate_id: sessionID, seq: count - 1 }) - .run() - db.insert(EventTable) - .values( - Array.from({ length: count }, (_, i) => ({ - id: `evt_${unique(`manual-${i}`)}`, - aggregate_id: sessionID, - seq: i, - type: sessionUpdatedType(), - data: { sessionID, info: { title: `manual ${i}` } }, - })), - ) - .run() - }) -} - describe("workspace-old schemas and exports", () => { test("keeps the historical event type names", () => { expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore") expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") }) @@ -374,17 +350,6 @@ describe("workspace-old schemas and exports", () => { expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) - - test("validates session restore input", () => { - const input = { - workspaceID: WorkspaceID.ascending("wrk_schema_restore"), - sessionID: SessionID.descending("ses_schema_restore"), - } - - expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow() - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow() - }) }) describe("workspace-old CRUD", () => { @@ -429,7 +394,7 @@ describe("workspace-old CRUD", () => { const workspaceID = WorkspaceID.ascending("wrk_create_local") const type = unique("create-local") const targetDir = path.join(dir, "created-local") - const recorded = recordedAdaptor({ + const recorded = recordedAdapter({ configure(info) { return { ...info, @@ -446,7 +411,7 @@ describe("workspace-old CRUD", () => { return { type: "local", directory: targetDir } }, }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ id: workspaceID, @@ -489,17 +454,17 @@ describe("workspace-old CRUD", () => { test("create propagates configure failures and does not insert a workspace", async () => { await withInstance(async () => { const type = unique("configure-failure") - registerAdaptor( + registerAdapter( Instance.project.id, type, - recordedAdaptor({ + recordedAdapter({ configure() { throw new Error("configure exploded") }, target() { return { type: "local", directory: "/unused" } }, - }).adaptor, + }).adapter, ) await expect( @@ -509,10 +474,10 @@ describe("workspace-old CRUD", () => { }) }) - test("create leaves the inserted row when adaptor create fails", async () => { + test("create leaves the inserted row when adapter create fails", async () => { await withInstance(async () => { const type = unique("create-failure") - const recorded = recordedAdaptor({ + const recorded = recordedAdapter({ async create() { throw new Error("create exploded") }, @@ -520,7 +485,7 @@ describe("workspace-old CRUD", () => { return { type: "local", directory: "/unused" } }, }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + registerAdapter(Instance.project.id, type, recorded.adapter) await expect( createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), @@ -538,8 +503,8 @@ describe("workspace-old CRUD", () => { await withInstance(async (dir) => { const type = unique("local-error") const missing = path.join(dir, "missing-local-target") - const recorded = localAdaptor(missing, { createDir: false }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = localAdapter(missing, { createDir: false }) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) @@ -576,8 +541,8 @@ describe("workspace-old CRUD", () => { Effect.gen(function* () { const workspace = yield* WorkspaceOld.Service const type = unique("remote-create") - const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) @@ -603,11 +568,11 @@ describe("workspace-old CRUD", () => { }) }) - test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => { + test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => { await withInstance(async (dir) => { const type = unique("remove-local") - const recorded = localAdaptor(path.join(dir, "remove-local")) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = localAdapter(path.join(dir, "remove-local")) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) @@ -628,21 +593,21 @@ describe("workspace-old CRUD", () => { }) }) - test("remove still deletes the row when the adaptor cannot remove resources", async () => { + test("remove still deletes the row when the adapter cannot remove resources", async () => { await withInstance(async () => { const type = unique("remove-throws") const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) - registerAdaptor( + registerAdapter( Instance.project.id, type, - recordedAdaptor({ + recordedAdapter({ async remove() { throw new Error("remove exploded") }, target() { return { type: "local", directory: "/unused" } }, - }).adaptor, + }).adapter, ) insertWorkspace(info) @@ -650,6 +615,144 @@ describe("workspace-old CRUD", () => { expect(await getWorkspace(info.id)).toBeUndefined() }) }) + + test("sessionWarp moves a session into a local workspace and claims ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-prev-local") + const targetType = unique("warp-target-local") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBe(target.id) + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }) + }) + + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-detach-local") + const previous = workspaceInfo(Instance.project.id, previousType) + insertWorkspace(previous) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id) + }) + }) + + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { + const calls: FetchCall[] = [] + let historySessionID: SessionID | undefined + let historyNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/warp-source/sync/history") { + return yield* HttpServerResponse.json([ + { + id: `evt_${unique("warp-source-history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from source history" } }, + }, + ]) + } + if (call.url.pathname === "/warp-target/sync/replay") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/sync/steal") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const previousType = unique("warp-remote-source") + const targetType = unique("warp-remote-target") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" }) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) + registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + const session = yield* sessionSvc.create({}) + attachSessionToWorkspace(session.id, previous.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + + expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ + "POST /warp-source/sync/history", + "POST /warp-target/sync/replay", + "POST /warp-target/sync/steal", + ]) + expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) + expect(calls[1].json).toMatchObject({ + directory: "remote-target-dir", + events: [ + { + aggregateID: session.id, + seq: 0, + type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version), + }, + { + aggregateID: session.id, + seq: historyNextSeq, + type: sessionUpdatedType(), + }, + ], + }) + expect(calls[2].json).toEqual({ sessionID: session.id }) + expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }), + { git: true }, + ) + }) + }) }) describe("workspace-old sync state", () => { @@ -661,7 +764,7 @@ describe("workspace-old sync state", () => { const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) attachSessionToWorkspace(session.id, info.id) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) startWorkspaceSyncing(Instance.project.id) await delay(25) @@ -682,8 +785,8 @@ describe("workspace-old sync state", () => { await fs.mkdir(withoutSessionDir, { recursive: true }) insertWorkspace(withSession) insertWorkspace(withoutSession) - registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor) - registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor) + registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) + registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, withSession.id, @@ -707,10 +810,10 @@ describe("workspace-old sync state", () => { const type = unique("missing-local") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor( + registerAdapter( Instance.project.id, type, - localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor, + localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter, ) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, @@ -738,7 +841,7 @@ describe("workspace-old sync state", () => { const target = path.join(dir, "dedupe-local") await fs.mkdir(target, { recursive: true }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(target).adapter) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id, @@ -795,7 +898,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-start") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -850,7 +953,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-connect-fail") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -890,7 +993,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-history-fail") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -947,7 +1050,7 @@ describe("workspace-old sync state", () => { const type = unique("history-replay") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter) const session = yield* sessionSvc.create({ title: "before history" }) attachSessionToWorkspace(session.id, info.id) historySessionID = session.id @@ -1014,7 +1117,7 @@ describe("workspace-old sync state", () => { const type = unique("sse-forward") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -1095,7 +1198,7 @@ describe("workspace-old sync state", () => { const type = unique("sse-sync") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) const session = yield* sessionSvc.create({ title: "before sse" }) attachSessionToWorkspace(session.id, info.id) sseSessionID = session.id @@ -1214,313 +1317,3 @@ describe("workspace-old waitForSync", () => { }) }, 7000) }) - -describe("workspace-old sessionRestore", () => { - test("throws when the workspace is missing", async () => { - await withInstance(async () => { - await expect( - restoreWorkspaceSession({ - workspaceID: WorkspaceID.ascending("wrk_restore_missing"), - sessionID: SessionID.descending("ses_restore_missing_workspace"), - }), - ).rejects.toThrow("Workspace not found: wrk_restore_missing") - }) - }) - - test("throws when switching a missing session fails", async () => { - await withInstance(async (dir) => { - const type = unique("restore-missing-session") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) - - await expect( - restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), - ).rejects.toThrow("NotFoundError") - await removeWorkspace(info.id) - }) - }) - - it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - const call = { - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - } - if (call.url.pathname === "/restore/sync/replay") { - replay.push(call) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - } - return HttpServerResponse.text("unexpected", { status: 500 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdaptor( - Instance.project.id, - type, - remoteAdaptor(`${url}/restore/?ignored=1#hash`, { - directory: dir, - headers: { authorization: "Bearer restore" }, - }).adaptor, - ) - const session = yield* sessionSvc.create({ title: "restore remote" }) - replaceSessionEvents(session.id, 24) - - const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }) - - expect(result).toEqual({ total: 3 }) - expect(replay).toHaveLength(3) - expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([ - "/restore/sync/replay", - "/restore/sync/replay", - "/restore/sync/replay", - ]) - expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true) - expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true) - expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5]) - expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir]) - expect( - replay.flatMap((call) => - (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq), - ), - ).toEqual(Array.from({ length: 25 }, (_, i) => i)) - expect( - (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1), - ).toMatchObject({ - seq: 24, - type: sessionUpdatedType(), - data: { sessionID: session.id, info: { workspaceID: info.id } }, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("remote restore sends an empty directory string when the workspace directory is null", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - () => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-null-dir") - const info = workspaceInfo(Instance.project.id, type, { directory: null }) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor) - const session = yield* sessionSvc.create({ title: "null dir" }) - replaceSessionEvents(session.id, 0) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - expect((replay[0].json as { directory: string }).directory).toBe("") - expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) - - it.live("remote restore failures include status and body and do not emit completed batch progress", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.text("replay failed", { status: 503 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote-fail") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor) - const session = yield* sessionSvc.create({ title: "restore fail" }) - replaceSessionEvents(session.id, 11) - - const error = yield* Effect.flip( - workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }), - ) - expect((error as Error).message).toContain( - `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`, - ) - - expect(replay).toHaveLength(1) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("local restore replays batches and emits progress", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-local") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) - const session = yield* sessionSvc.create({ title: "restore local" }) - replaceSessionEvents(session.id, 20) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 3, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ), - ) - - it.live("session restore includes real message and part events in sequence order", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-real-events") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor) - const session = yield* sessionSvc.create({ title: "real events" }) - for (let i = 0; i < 3; i++) { - const msg = yield* sessionSvc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* sessionSvc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: msg.id, - type: "text", - text: `message ${i}`, - }) - } - const before = eventRows(session.id) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - - const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events - expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1]) - expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type)) - expect(posted.at(-1)?.type).toBe(sessionUpdatedType()) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) -}) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 54b2b42c8f..f5e6933883 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -4,7 +4,7 @@ import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { Instance } from "../../src/project/instance" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -19,7 +19,7 @@ const tmpdirGitScoped = Effect.gen(function* () { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) it.live("InstanceState caches values per directory", () => @@ -69,7 +69,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -94,7 +94,7 @@ it.live("InstanceState invalidates on disposeAll", () => yield* access(state, one) yield* access(state, two) - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) expect(seen.sort()).toEqual([one, two].sort()) }), @@ -269,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => const [, b] = yield* Effect.all( [ - Effect.promise(() => Instance.reload({ directory: one })), + Effect.promise(() => reloadTestInstance({ directory: one })), Test.use((svc) => svc.get()).pipe(provideInstance(two)), ], { concurrency: "unbounded" }, diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index e9baf88538..16538bb8ae 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -1,9 +1,12 @@ import { expect } from "bun:test" import { Effect, Layer, Context } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" +import { ProjectID } from "../../src/project/schema" import { it } from "../lib/effect" class Shared extends Context.Service()("@test/Shared") {} +const testDirectory = "/tmp/opencode-test" it.live("makeRuntime shares dependent layers through the shared memo map", () => Effect.gen(function* () { @@ -47,3 +50,40 @@ it.live("makeRuntime shares dependent layers through the shared memo map", () => expect(n).toBe(1) }), ) + +it.live("makeRuntime inherits InstanceRef from the current fiber", () => + Effect.gen(function* () { + class NeedsInstance extends Context.Service< + NeedsInstance, + { readonly directory: () => Effect.Effect } + >()("@test/NeedsInstance") {} + + const runtime = makeRuntime( + NeedsInstance, + Layer.succeed( + NeedsInstance, + NeedsInstance.of({ + directory: () => + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + }), + ), + ) + + const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory())) + + expect(actual).toBe(testDirectory) + }).pipe( + Effect.provideService(InstanceRef, { + directory: testDirectory, + worktree: testDirectory, + project: { + id: ProjectID.global, + worktree: testDirectory, + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), + ), +) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 699e713c22..f345cd0850 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -30,7 +31,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await status() @@ -55,7 +56,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await read("tracked.txt") diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 091626be8d..cdd2e211c2 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,11 +5,12 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const init = () => run(File.Service.use((svc) => svc.init())) @@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "Hello World", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => { test("reads with Filesystem.exists() check", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Non-existent file should return empty content @@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "empty.txt") await fs.writeFile(filepath, "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("empty.txt") @@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "multiline.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("multiline.txt") @@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => { const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await fs.writeFile(filepath, binaryContent) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("image.png") @@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "binary.so") await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("binary.so") @@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.json") await fs.writeFile(filepath, '{"key": "value"}', "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain("application/json") @@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, `test.${ext}`) await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain(mime) @@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => { test("reads .ignore file similarly", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ignorePath = path.join(tmp.path, ".ignore") @@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => { test("handles missing .gitignore gracefully", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => { test("reads untracked files via Filesystem.readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const untrackedPath = path.join(tmp.path, "untracked.txt") @@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "readonly.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") @@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => { test("handles errors in Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") @@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => { const _filepath = path.join(tmp.path, "broken.png") // Don't create the file - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // read() handles missing images gracefully @@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.ts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.ts") @@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.mts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.mts") @@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.sh") await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.sh") @@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "Dockerfile") await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("Dockerfile") @@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "simple text", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.jpg") await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.jpg") @@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.rm(filepath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => { await fs.rm(path.join(tmp.path, "remove.txt")) await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for non-git project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for clean repo", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => { for (let i = 0; i < 512; i++) modified[i] = i % 256 await fs.writeFile(filepath, modified) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") await fs.mkdir(path.join(tmp.path, "build")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list("sub") @@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../outside")).rejects.toThrow("Access denied") @@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir() await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -692,7 +693,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -706,7 +707,7 @@ describe("file/index Filesystem patterns", () => { test("search works before explicit init", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await search({ query: "main", type: "file" }) @@ -718,7 +719,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -738,7 +739,7 @@ describe("file/index Filesystem patterns", () => { test("fuzzy matches file names", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -752,7 +753,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -769,7 +770,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only directories", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -786,7 +787,7 @@ describe("file/index Filesystem patterns", () => { test("respects limit", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -800,7 +801,7 @@ describe("file/index Filesystem patterns", () => { test("query starting with dot prefers hidden files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -815,7 +816,7 @@ describe("file/index Filesystem patterns", () => { test("search refreshes after init when files change", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -839,7 +840,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified content\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("file.txt") @@ -863,7 +864,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(filepath, "after\n", "utf-8") await $`git add .`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("staged.txt") @@ -880,7 +881,7 @@ describe("file/index Filesystem patterns", () => { await $`git add .`.cwd(tmp.path).quiet() await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("clean.txt") @@ -900,7 +901,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: one.path, fn: async () => { await init() @@ -911,7 +912,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: two.path, fn: async () => { await init() @@ -927,7 +928,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -936,12 +937,12 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.disposeAll() + await disposeAllInstances() await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index a52af7023a..5b59929ea5 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,8 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -54,7 +56,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") @@ -65,7 +67,7 @@ describe("File.read path traversal protection", () => { test("rejects deeply nested traversal", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( @@ -82,7 +84,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("valid.txt") @@ -96,7 +98,7 @@ describe("File.list path traversal protection", () => { test("rejects ../ traversal attempting to list /etc", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") @@ -111,7 +113,7 @@ describe("File.list path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await list("subdir") @@ -121,15 +123,15 @@ describe("File.list path traversal protection", () => { }) }) -describe("Instance.containsPath", () => { +describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true) + expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -139,15 +141,15 @@ describe("Instance.containsPath", () => { const subdir = path.join(tmp.path, "packages", "lib") await fs.mkdir(subdir, { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true) + expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true) + expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path)).toBe(true) + expect(containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -155,11 +157,11 @@ describe("Instance.containsPath", () => { test("returns false for path outside both directory and worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other-project")).toBe(false) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -167,10 +169,10 @@ describe("Instance.containsPath", () => { test("returns false for path with .. escaping worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false) + expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -178,12 +180,12 @@ describe("Instance.containsPath", () => { test("handles directory === worktree (running from repo root)", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -191,13 +193,13 @@ describe("Instance.containsPath", () => { test("non-git project does not allow arbitrary paths via worktree='/'", async () => { await using tmp = await tmpdir() // no git: true - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other")).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2c2663b0e0..7e47c51351 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -3,12 +3,13 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ function withWatcher(directory: string, body: Effect.Effect) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( @@ -147,7 +148,7 @@ function ready(directory: string) { describeWatcher("FileWatcher", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("publishes root create, update, and delete events", async () => { @@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => { await withWatcher(tmp.path, Effect.void) // Now write a file — no watcher should be listening - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts new file mode 100644 index 0000000000..9dd5f3910e --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -0,0 +1,6 @@ +// Separate file because every export in `agent-plugin.ts` must be a function. +export const PLUGIN_AGENT = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts new file mode 100644 index 0000000000..892f636466 --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -0,0 +1,12 @@ +// Every export in this file must be a plugin function — `getLegacyPlugins` +// (src/plugin/index.ts) throws on anything else. Test constants live in +// `agent-plugin.constants.ts`. +export default async () => ({ + config: async (cfg: { agent?: Record }) => { + cfg.agent = cfg.agent ?? {} + cfg.agent["plugin_added"] = { + description: "Added by a plugin via the config hook", + mode: "subagent", + } + }, +}) diff --git a/packages/opencode/test/fixture/config.ts b/packages/opencode/test/fixture/config.ts new file mode 100644 index 0000000000..4cd90c51bf --- /dev/null +++ b/packages/opencode/test/fixture/config.ts @@ -0,0 +1,23 @@ +import { Config } from "@/config/config" +import { emptyConsoleState } from "@/config/console-state" +import { Effect, Layer } from "effect" + +export function make(overrides: Partial = {}) { + return Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed({ info: config, changed: false }), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + ...overrides, + }) +} + +export function layer(overrides?: Partial) { + return Layer.succeed(Config.Service, make(overrides)) +} + +export * as TestConfig from "./config" diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 4e83d0b906..07b42d9946 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,9 +1,9 @@ import { rm } from "fs/promises" -import { Instance } from "../../src/project/instance" import { Database } from "@/storage/db" +import { disposeAllInstances } from "./fixture" export async function resetDatabase() { - await Instance.disposeAll().catch(() => undefined) + await disposeAllInstances().catch(() => undefined) Database.close() await rm(Database.Path, { force: true }).catch(() => undefined) await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 91605e15ad..d47620f623 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,16 +1,47 @@ import { $ } from "bun" +import * as Observability from "@opencode-ai/core/effect/observability" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, Context } from "effect" +import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const testInstanceRuntime = ManagedRuntime.make( + InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)), +) + +const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => + testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) + +export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) + try { + if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) + return await Instance.restore(ctx, () => input.fn()) + } finally { + await runTestInstanceStore((store) => store.dispose(ctx)) + } +} + +export async function reloadTestInstance(input: { directory: string }) { + return runTestInstanceStore((store) => store.reload(input)) +} + +export async function disposeAllInstances() { + await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())]) +} + // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { return p.replace(/\0/g, "") @@ -124,12 +155,12 @@ export const provideInstance = (directory: string) => (self: Effect.Effect): Effect.Effect => Effect.contextWith((services: Context.Context) => - Effect.promise(async () => - Instance.provide({ - directory, - fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))), - }), - ), + Effect.promise(async () => { + const ctx = await runTestInstanceStore((store) => store.load({ directory })) + return Instance.restore(ctx, () => + Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))), + ) + }), ) export function provideTmpdirInstance( @@ -143,10 +174,9 @@ export function provideTmpdirInstance( yield* Effect.addFinalizer(() => provided ? Effect.promise(() => - Instance.provide({ - directory: path, - fn: () => Instance.dispose(), - }), + runTestInstanceStore((store) => + store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))), + ), ).pipe(Effect.ignore) : Effect.void, ) @@ -156,6 +186,21 @@ export function provideTmpdirInstance( }) } +export class TestInstance extends Context.Service()("@test/Instance") {} + +export const withTmpdirInstance = + (options?: { git?: boolean; config?: Partial }) => + (self: Effect.Effect) => + Effect.gen(function* () { + const directory = yield* tmpdirScoped(options) + return yield* InstanceStore.Service.use((store) => + store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))), + ) + }).pipe( + Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) + export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, options?: { git?: boolean; config?: (url: string) => Partial }, diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index a897a38e68..1e56865d72 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -114,6 +114,53 @@ describe("Git", () => { }) }) + test("patch() returns capped native patch output", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + + await withGit(async (rt) => { + const [patch, all, capped] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("-before") + expect(patch.text).toContain("+after") + expect(all.truncated).toBe(false) + expect(all.text).toContain("diff --git") + expect(all.text).toContain("other.txt") + expect(all.text).toContain("+new") + expect(capped.truncated).toBe(true) + expect(capped.text).toBe("") + }) + }) + + test("patchUntracked() and statUntracked() handle added files", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + + await withGit(async (rt) => { + const [patch, stat] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("+one") + expect(patch.text).toContain("+two") + expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) + }) + }) + test("show() returns empty text for binary blobs", async () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 131ec5cc6b..e454fa7e42 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -3,8 +3,24 @@ import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" +import type { Config } from "@/config/config" +import { TestInstance, withTmpdirInstance } from "../fixture/fixture" type Body = Effect.Effect | (() => Effect.Effect) +type InstanceOptions = { git?: boolean; config?: Partial } + +function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, + testOptions?: number | TestOptions, +): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { + if (typeof options === "number") return { instanceOptions: undefined, testOptions: options } + if (isInstanceOptions(options)) return { instanceOptions: options, testOptions } + return { instanceOptions: undefined, testOptions: options } +} const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) @@ -38,7 +54,49 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - return { effect, live } + const instance = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) + } + + instance.only = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.only( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) + } + + instance.skip = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.skip( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) + } + + return { effect, live, instance } } // Test environment with TestClock and TestConsole diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f3c7893d97..7d9f5a7155 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" function spawnFakeServer() { @@ -25,7 +26,7 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -48,7 +49,7 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -71,7 +72,7 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -94,7 +95,7 @@ describe("LSPClient interop", () => { test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -121,7 +122,7 @@ describe("LSPClient interop", () => { gamma: true, } - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -150,7 +151,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "first\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -193,7 +194,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -239,7 +240,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -286,7 +287,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -334,7 +335,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -387,7 +388,7 @@ describe("LSPClient interop", () => { await Bun.write(file, "class C {}\n") await Bun.write(related, "class D {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -451,7 +452,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 175717d056..5bc8f803d2 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -48,6 +48,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -73,7 +74,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options @@ -112,7 +113,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( test("headers are passed to transports when oauth is explicitly disabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 @@ -150,7 +151,7 @@ test("headers are passed to transports when oauth is explicitly disabled", async test("no requestInit when headers are not provided", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 1b459481f3..10547c9f08 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,4 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -171,6 +172,7 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") // --- Helper --- @@ -192,12 +194,12 @@ function withInstance( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await Instance.dispose() + await InstanceRuntime.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 8b29f6d1e3..3cf6774215 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,6 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") test("first connect to OAuth server shows needs_auth instead of failed", async () => { @@ -132,7 +133,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await Effect.runPromise( @@ -162,7 +163,7 @@ test("state() generates a new state when none is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -203,7 +204,7 @@ test("state() returns existing state when one is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -252,7 +253,7 @@ test("authenticate() stores a connected client when auth completes without redir }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise( diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 3a6df02a15..20cb90a18e 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,6 +106,7 @@ const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -127,7 +128,7 @@ test("BrowserOpenFailed event is published when open() throws", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = true @@ -183,7 +184,7 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false @@ -237,7 +238,7 @@ test("open() is called with the authorization URL", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 5ce7eee939..64b93bb8bc 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,13 +2,14 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" -import { tmpdir } from "./fixture/fixture" +import { WithInstance } from "../src/project/with-instance" +import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("Permission.evaluate for permission.task", () => { @@ -158,7 +159,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -183,7 +184,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -208,7 +209,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -235,7 +236,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -273,7 +274,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -304,7 +305,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 0064185f46..1c3d6fc563 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,7 +6,15 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { + disposeAllInstances, + provideInstance, + provideTmpdirInstance, + reloadTestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -15,7 +23,7 @@ const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, Cross const it = testEffect(env) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const rejectAll = (message?: string) => @@ -998,7 +1006,9 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() })) + yield* Effect.promise(() => + WithInstance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), + ) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1021,7 +1031,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1115,7 +1125,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 4bee985796..c77c0ca1c0 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,11 +1,40 @@ import { describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect } from "effect" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { pathToFileURL } from "url" +import { Effect, Layer } from "effect" +import { provideTestInstance, tmpdir } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" +import { Plugin } from "@/plugin" +import { Auth } from "@/auth" +import { Bus } from "@/bus" +import { TestConfig } from "../fixture/config" + +function layer(directory: string, plugins: string[]) { + return ProviderAuth.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), + }), + directories: () => Effect.succeed([directory]), + }), + ), + ), + ), + ) +} describe("plugin.auth-override", () => { test("user plugin overrides built-in github-copilot auth", async () => { @@ -37,30 +66,32 @@ describe("plugin.auth-override", () => { await using plain = await tmpdir() - const methods = await Instance.provide({ - directory: tmp.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) - - const plainMethods = await Instance.provide({ - directory: plain.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) + const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href + const [methods, plainMethods] = await Promise.all([ + provideTestInstance({ + directory: tmp.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))), + ) + }, + }), + provideTestInstance({ + directory: plain.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))), + ) + }, + }), + ]) const copilot = methods[ProviderID.make("github-copilot")] expect(copilot).toBeDefined() expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") - }, 30000) // Increased timeout for plugin installation + }, 30000) }) const file = path.join(import.meta.dir, "../../src/plugin/index.ts") diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 211a93a602..8c55950aff 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -12,8 +12,9 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { Instance } = await import("../../src/project/instance") +const { Bus } = await import("../../src/bus") const { Npm } = await import("@opencode-ai/core/npm") +const { TestConfig } = await import("../fixture/config") afterAll(() => { if (disableDefault === undefined) { @@ -24,18 +25,35 @@ afterAll(() => { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function load(dir: string) { - return Instance.provide({ - directory: dir, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.list() - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) + const source = path.join(dir, "opencode.json") + const config = (await Bun.file(source).json()) as { plugin?: Array]> } + const plugins = config.plugin ?? [] + return Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ), + ), + ), + provideInstance(dir), + Effect.runPromise, + ) } describe("plugin.loader.shared", () => { diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts similarity index 93% rename from packages/opencode/test/plugin/workspace-adaptor.test.ts rename to packages/opencode/test/plugin/workspace-adapter.test.ts index 677c004be4..249087808d 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { pathToFileURL } from "url" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -20,7 +20,7 @@ const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) afterAll(() => { @@ -34,7 +34,7 @@ afterAll(() => { }) describe("plugin.workspace", () => { - it.live("plugin can install a workspace adaptor", () => + it.live("plugin can install a workspace adapter", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const type = `plug-${Math.random().toString(36).slice(2)}` @@ -48,7 +48,7 @@ describe("plugin.workspace", () => { "export default async ({ experimental_workspace }) => {", ` experimental_workspace.register(${JSON.stringify(type)}, {`, ' name: "plug",', - ' description: "plugin workspace adaptor",', + ' description: "plugin workspace adapter",', " configure(input) {", ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, " },", diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 479da7f518..b408f7ef11 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") +process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills @@ -79,7 +80,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"] process.env["OPENCODE_DB"] = ":memory:" // Now safe to import from src/ -const Log = await import("@opencode-ai/core/util/log") +const { Log } = await import("@opencode-ai/core/util/log") const { initProjectors } = await import("../src/server/projectors") void Log.init({ diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts new file mode 100644 index 0000000000..c01450549b --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -0,0 +1,86 @@ +import { afterEach, expect, test } from "bun:test" +import { Hono } from "hono" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +// These regressions cover the legacy instance-loading paths fixed by PRs +// #25389 and #25449. The plugin config hook writes a marker file, and the test +// bodies deliberately avoid touching Plugin or config directly. The marker only +// exists if InstanceBootstrap ran at the instance boundary. + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { + await using tmp = await bootstrapFixture() + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { + await using tmp = await bootstrapFixture() + const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) + + const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 0000000000..99b0f0666b --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Fiber, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" +import { registerDisposer } from "../../src/effect/instance-registry" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceStore } from "../../src/project/instance-store" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) + +const it = testEffect( + Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), +) + +afterEach(async () => { + bootstrapRun = Effect.void + await disposeAllInstances() +}) + +describe("InstanceStore", () => { + it.live("loads instance context without installing ALS for the caller", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const ctx = yield* store.load({ directory: dir }) + + expect(ctx.directory).toBe(dir) + expect(ctx.worktree).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("runs bootstrap with InstanceRef provided", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initializedDirectory: string | undefined + + bootstrapRun = Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory + }) + yield* store.load({ directory: dir }) + + expect(initializedDirectory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("caches loaded instance context by directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initialized = 0 + + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const first = yield* store.load({ directory: dir }) + const second = yield* store.load({ directory: dir }) + + expect(second).toBe(first) + expect(initialized).toBe(1) + }), + ) + + it.live("dedupes concurrent loads while init is in flight", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let initialized = 0 + + bootstrapRun = Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }) + const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) + + yield* Effect.promise(() => started.promise) + + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) + + expect(initialized).toBe(1) + release.resolve() + + const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(secondCtx).toBe(firstCtx) + expect(initialized).toBe(1) + }), + ) + + it.live("removes failed loads from the cache", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let attempts = 0 + + bootstrapRun = Effect.sync(() => { + attempts++ + throw new Error("init failed") + }) + const failed = yield* store.load({ directory: dir }).pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) + + expect(failed).toBe(true) + + bootstrapRun = Effect.sync(() => { + attempts++ + }) + const ctx = yield* store.load({ directory: dir }) + + expect(ctx.directory).toBe(dir) + expect(attempts).toBe(2) + }), + ) + + it.live("reload replaces the cached context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + const first = yield* store.load({ directory: dir }) + const second = yield* store.reload({ directory: dir }) + const cached = yield* store.load({ directory: dir }) + + expect(second).not.toBe(first) + expect(cached).toBe(second) + }), + ) + + it.live("stale dispose does not delete an in-flight reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const reloading = Promise.withResolvers() + const releaseReload = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const first = yield* store.load({ directory: dir }) + bootstrapRun = Effect.promise(async () => { + reloading.resolve() + await releaseReload.promise + }) + const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) + + yield* Effect.promise(() => reloading.promise) + const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) + releaseReload.resolve() + + const second = yield* Fiber.join(reload) + yield* Fiber.join(staleDispose) + + expect(disposed).toEqual([dir]) + expect(yield* store.load({ directory: dir })).toBe(second) + }), + ) + + it.live("dedupes concurrent disposeAll calls", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposing = Promise.withResolvers() + const releaseDispose = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + disposing.resolve() + await releaseDispose.promise + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir }) + const first = yield* store.disposeAll().pipe(Effect.forkScoped) + yield* Effect.promise(() => disposing.promise) + const second = yield* store.disposeAll().pipe(Effect.forkScoped) + + expect(disposed).toEqual([dir]) + releaseDispose.resolve() + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(disposed).toEqual([dir]) + }), + ) + + it.live("re-arms disposeAll after completion", () => + Effect.gen(function* () { + const dir1 = yield* tmpdirScoped({ git: true }) + const dir2 = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir1 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1]) + + yield* store.load({ directory: dir2 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1, dir2]) + }), + ) + + it.live("provides legacy Promise callers with instance ALS", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + const directory = yield* Effect.promise(() => + WithInstance.provide({ + directory: dir, + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) +}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e69b8e6df2..9906b31645 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Bus } from "@/bus" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) { function projectLayerWithFailure(failArg: string) { return Project.layer.pipe( Layer.provide(mockGitFailure(failArg)), + Layer.provide(Bus.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index a2a5cff601..82eacfb6df 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,12 +1,14 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { parsePatch } from "diff" import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { GlobalBus } from "../../src/bus/global" import { Vcs } from "@/project/vcs" @@ -18,7 +20,7 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe // --------------------------------------------------------------------------- async function withVcs(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -36,7 +38,7 @@ async function withVcs(directory: string, body: () => Promise) { } function withVcsOnly(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -85,7 +87,7 @@ function nextBranchUpdate(directory: string, timeout = 10_000) { describeVcs("Vcs", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("branch() returns current branch name", async () => { @@ -158,7 +160,7 @@ describeVcs("Vcs", () => { describe("Vcs diff", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("defaultBranch() falls back to main", async () => { @@ -233,6 +235,7 @@ describe("Vcs diff", () => { }), ]), ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") }) }) @@ -258,6 +261,56 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps batched patches aligned for type changes", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() + await fs.unlink(path.join(tmp.path, "a.txt")) + await fs.symlink("target", path.join(tmp.path, "a.txt")) + await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") + + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }) + }) + + test("diff('git') keeps carriage returns inside patch hunks", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, 20_000) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 44a25a8e6b..b191a3c952 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,8 +5,10 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -37,7 +39,7 @@ async function waitReady() { } describe("Worktree", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("makeWorktreeInfo", () => { it.live("returns info with name, branch, and directory", () => @@ -136,7 +138,12 @@ describe("Worktree", () => { expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => + WithInstance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -156,7 +163,12 @@ describe("Worktree", () => { expect(info.branch).toBe("opencode/test-workspace") yield* Effect.promise(() => ready) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => + WithInstance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -166,12 +178,13 @@ describe("Worktree", () => { }) describe("createFromInfo", () => { - wintest("creates and bootstraps git worktree", () => + wintest("creates git worktree and boots asynchronously", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo("from-info-test") + const ready = waitReady() yield* svc.createFromInfo(info) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) @@ -179,6 +192,7 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) + yield* Effect.promise(() => ready) yield* svc.remove({ directory: info.directory }) }), { git: true }, diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 88c67aa6dc..c35a03d78b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -5,6 +5,7 @@ import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" import { Global } from "@opencode-ai/core/global" @@ -43,13 +44,11 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -68,13 +67,11 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -123,14 +120,12 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -169,13 +164,11 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -201,12 +194,10 @@ test("Bedrock: includes custom endpoint in options when specified", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("AWS_PROFILE", "default") - }, fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( @@ -234,15 +225,13 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -277,12 +266,10 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("AWS_PROFILE", "default") - }, fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix @@ -314,12 +301,10 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("AWS_PROFILE", "default") - }, fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -350,12 +335,10 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("AWS_PROFILE", "default") - }, fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -386,12 +369,10 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("AWS_PROFILE", "default") - }, fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 84478a34c4..8bb3b96347 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -9,6 +9,7 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" // import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" // import { Global } from "@opencode-ai/core/global" @@ -25,7 +26,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-gitlab-token") @@ -56,7 +57,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -95,7 +96,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -130,7 +131,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -162,7 +163,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") @@ -193,7 +194,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "env-token") @@ -216,7 +217,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -252,7 +253,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -277,7 +278,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -301,7 +302,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -349,7 +350,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -372,7 +373,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -396,7 +397,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts new file mode 100644 index 0000000000..7ccf126a9c --- /dev/null +++ b/packages/opencode/test/provider/models.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { ModelsDev } from "../../src/provider/models" +import { it } from "../lib/effect" +import { rm, writeFile, utimes, mkdir } from "fs/promises" +import path from "path" + +// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can +// resolve providers without network. These tests need to drive the on-disk +// cache themselves and silence the eager refresh fork. Save/restore around +// the suite — never leak the mutation to subsequent test files in the same +// bun process. +const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH +const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH +beforeAll(() => { + Flag.OPENCODE_MODELS_PATH = undefined + Flag.OPENCODE_DISABLE_MODELS_FETCH = true +}) +afterAll(() => { + Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH + Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH +}) + +const cacheFile = path.join(Global.Path.cache, "models.json") + +const fixture: Record = { + acme: { + id: "acme", + name: "Acme", + env: ["ACME_API_KEY"], + models: { + "acme-1": { + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }, + }, + }, +} + +const fixture2: Record = { + beta: { + id: "beta", + name: "Beta", + env: ["BETA_API_KEY"], + models: { + "beta-1": { + id: "beta-1", + name: "Beta One", + release_date: "2026-02-01", + attachment: false, + reasoning: true, + temperature: false, + tool_call: false, + limit: { context: 64000, output: 4096 }, + }, + }, + }, +} + +interface MockState { + body: string + status: number + calls: Array<{ url: string }> +} + +const makeMockClient = (state: Ref.Ref) => + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] })) + const s = yield* Ref.get(state) + return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) + }), + ) + +const buildLayer = (state: Ref.Ref) => + // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // and Effect.provide uses a process-global MemoMap by default — without fresh, + // every test would reuse the cachedInvalidateWithTTL state from the first run. + Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + Layer.provide(AppFileSystem.defaultLayer), + ) + +const writeCache = (data: object, mtimeMs?: number) => + Effect.promise(async () => { + await mkdir(Global.Path.cache, { recursive: true }) + await writeFile(cacheFile, JSON.stringify(data)) + if (mtimeMs !== undefined) { + const t = mtimeMs / 1000 + await utimes(cacheFile, t, t) + } + }) + +const provided = (state: Ref.Ref, eff: Effect.Effect) => + eff.pipe(Effect.provide(buildLayer(state))) + +beforeEach(async () => { + await rm(cacheFile, { force: true }) +}) + +afterAll(async () => { + await rm(cacheFile, { force: true }) +}) + +const initialState: MockState = { + body: JSON.stringify(fixture), + status: 200, + calls: [], +} + +describe("ModelsDev Service", () => { + it.live("get() returns providers from disk when cache file exists", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual(fixture) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() returns {} when disk empty and fetch disabled", () => + Effect.gen(function* () { + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual({}) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() is single-flight under concurrent calls", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const results = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { + concurrency: "unbounded", + }) + }), + ) + for (const result of results) expect(result).toEqual(fixture) + }), + ) + + it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const first = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const a = yield* svc.get() + // mutate disk between calls — cache should mask the change + yield* writeCache(fixture2) + const b = yield* svc.get() + return { a, b } + }), + ) + expect(first.a).toEqual(fixture) + expect(first.b).toEqual(fixture) + }), + ) + + it.live("refresh(true) fetches via HttpClient and updates the cache", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const before = yield* svc.get() + yield* svc.refresh(true) + const after = yield* svc.get() + return { before, after } + }), + ) + expect(result.before).toEqual(fixture) + expect(result.after).toEqual(fixture2) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(final.calls[0].url).toContain("/api.json") + }), + ) + + it.live("refresh(false) skips fetch when on-disk file is fresh", () => + Effect.gen(function* () { + // Fresh: mtime within the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + yield* provided( + state, + ModelsDev.Service.use((s) => s.refresh(false)), + ) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("refresh(false) fetches when on-disk file is stale", () => + Effect.gen(function* () { + // Stale: mtime 10 minutes ago, beyond the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const after = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(false) + return yield* svc.get() + }), + ) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(after).toEqual(fixture2) + }), + ) + + it.live("refresh swallows HTTP errors and leaves cache intact", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(true) + return yield* svc.get() + }), + ) + expect(result).toEqual(fixture) + // withTransientReadRetry retries 5xx, so calls may be > 1. + const final = yield* Ref.get(state) + expect(final.calls.length).toBeGreaterThanOrEqual(1) + }), + ) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d0aa299e70..cdb9d20572 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2,9 +2,10 @@ import { test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" @@ -80,12 +81,10 @@ test("provider loaded from env variable", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders @@ -114,7 +113,7 @@ test("provider loaded from config with apiKey option", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -135,12 +134,10 @@ test("disabled_providers excludes provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -159,13 +156,11 @@ test("enabled_providers restricts to only listed providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -189,12 +184,10 @@ test("model whitelist filters models for provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -220,12 +213,10 @@ test("model blacklist excludes specific models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -255,12 +246,10 @@ test("custom model alias via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() @@ -301,7 +290,7 @@ test("custom provider with npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -358,7 +347,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -392,12 +381,10 @@ test("env variable takes precedence, config merges options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "env-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged @@ -418,12 +405,10 @@ test("getModel returns model for valid provider/model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") @@ -445,12 +430,10 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) @@ -467,7 +450,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() @@ -498,12 +481,10 @@ test("defaultModel returns first available model when no config set", async () = ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() @@ -523,12 +504,10 @@ test("defaultModel respects config model setting", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") @@ -565,7 +544,7 @@ test("provider with baseURL from config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -603,7 +582,7 @@ test("model cost defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -638,12 +617,10 @@ test("model options are merged from existing model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") @@ -667,12 +644,10 @@ test("provider removed when all models filtered out", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -690,12 +665,10 @@ test("closest finds model by partial match", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") @@ -715,7 +688,7 @@ test("closest returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await closest(ProviderID.make("nonexistent"), ["model"]) @@ -745,12 +718,10 @@ test("getModel uses realIdByKey for aliased models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -791,7 +762,7 @@ test("provider api field sets model api.url", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -831,7 +802,7 @@ test("explicit baseURL overrides api field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -860,12 +831,10 @@ test("model inherits properties from existing database model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") @@ -888,12 +857,10 @@ test("disabled_providers prevents loading even with env var", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("OPENAI_API_KEY", "test-openai-key") - }, fn: async () => { + set("OPENAI_API_KEY", "test-openai-key") const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -912,13 +879,11 @@ test("enabled_providers with empty array allows no providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, - fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) }, @@ -942,12 +907,10 @@ test("whitelist and blacklist can be combined", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -984,7 +947,7 @@ test("model modalities default correctly", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1027,7 +990,7 @@ test("model with custom cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1051,12 +1014,10 @@ test("getSmallModel returns appropriate small model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") @@ -1076,12 +1037,10 @@ test("getSmallModel respects config small_model override", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") @@ -1124,13 +1083,11 @@ test("multiple providers can be configured simultaneously", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() @@ -1169,7 +1126,7 @@ test("provider with custom npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1203,12 +1160,10 @@ test("model alias name defaults to alias key when id differs", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, @@ -1243,12 +1198,10 @@ test("provider with multiple env var options only includes apiKey when single en ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("MULTI_ENV_KEY_1", "test-key") - }, fn: async () => { + set("MULTI_ENV_KEY_1", "test-key") const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set @@ -1285,12 +1238,10 @@ test("provider with single env var includes apiKey automatically", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("SINGLE_ENV_KEY", "my-api-key") - }, fn: async () => { + set("SINGLE_ENV_KEY", "my-api-key") const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key @@ -1322,12 +1273,10 @@ test("model cost overrides existing cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) @@ -1372,7 +1321,7 @@ test("completely new provider not in database can be configured", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1401,14 +1350,12 @@ test("disabled_providers and enabled_providers interaction", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }, - fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1446,7 +1393,7 @@ test("model with tool_call false", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1481,7 +1428,7 @@ test("model defaults tool_call to true when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1520,7 +1467,7 @@ test("model headers are preserved", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1559,13 +1506,11 @@ test("provider env fallback - second env var used if first missing", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }, - fn: async () => { const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() @@ -1584,12 +1529,10 @@ test("getModel returns consistent results", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) @@ -1625,7 +1568,7 @@ test("provider name defaults to id when not in database", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1645,12 +1588,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here @@ -1673,12 +1614,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here @@ -1701,7 +1640,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const provider = await getProvider(ProviderID.make("nonexistent")) @@ -1721,12 +1660,10 @@ test("getProvider returns provider info", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") @@ -1745,12 +1682,10 @@ test("closest returns undefined when no partial match found", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, @@ -1768,12 +1703,10 @@ test("closest checks multiple query terms in order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() @@ -1808,7 +1741,7 @@ test("model limit defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1840,12 +1773,10 @@ test("provider options are deeply merged", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1878,12 +1809,10 @@ test("custom model inherits npm package from models.dev provider config", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("OPENAI_API_KEY", "test-api-key") - }, fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() @@ -1913,12 +1842,10 @@ test("custom model inherits api.url from models.dev provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("OPENROUTER_API_KEY", "test-api-key") - }, fn: async () => { + set("OPENROUTER_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2046,12 +1973,10 @@ test("model variants are generated for reasoning models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2084,12 +2009,10 @@ test("model variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2127,12 +2050,10 @@ test("model variants can be customized via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2166,12 +2087,10 @@ test("disabled key is stripped from variant config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() @@ -2204,12 +2123,10 @@ test("all variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2242,12 +2159,10 @@ test("variant config merges with generated variants", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }, fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2280,12 +2195,10 @@ test("variants filtered in second pass for database models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("OPENAI_API_KEY", "test-api-key") - }, fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() @@ -2329,7 +2242,7 @@ test("custom model with variants enabled and disabled", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -2384,12 +2297,10 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") @@ -2429,12 +2340,10 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2455,14 +2364,12 @@ test("cloudflare-ai-gateway loads with env variables", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, @@ -2487,14 +2394,12 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ @@ -2542,7 +2447,7 @@ test("plugin config providers persist after instance dispose", async () => { }, }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise( @@ -2557,9 +2462,9 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - await Instance.disposeAll() + await disposeAllInstances() - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => list(), }) @@ -2590,13 +2495,11 @@ test("plugin config enabled and disabled providers are honored", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -2616,7 +2519,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2639,7 +2542,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) @@ -2660,7 +2563,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2694,7 +2597,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }), ) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 9ef9741bad..662042b64c 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" @@ -10,7 +11,7 @@ describe("pty", () => { test("does not leak output when websocket objects are reused", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -60,7 +61,7 @@ describe("pty", () => { test("does not leak output when Bun recycles websocket objects before re-connect", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -105,7 +106,7 @@ describe("pty", () => { test("treats in-place socket data mutation as the same connection", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d658355..8c5d804b73 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -3,6 +3,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" @@ -27,7 +28,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -68,7 +69,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 7b8b4d67ca..00e965d25e 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" import { tmpdir } from "../fixture/fixture" @@ -17,7 +18,7 @@ describe("pty shell args", () => { "does not add login args to pwsh", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -47,7 +48,7 @@ describe("pty shell args", () => { "adds login args to bash", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -78,7 +79,7 @@ describe("pty configured shell", () => { await using dir = await tmpdir({ config: { shell: Shell.name(configured) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts new file mode 100644 index 0000000000..4886f250f9 --- /dev/null +++ b/packages/opencode/test/pty/ticket.test.ts @@ -0,0 +1,57 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { WorkspaceID } from "../../src/control-plane/schema" +import { PtyID } from "../../src/pty/schema" +import { PtyTicket } from "../../src/pty/ticket" +import { testEffect } from "../lib/effect" + +const it = testEffect(PtyTicket.layer) +const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) + +describe("PTY websocket tickets", () => { + it.live("consumes tickets once", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" } + const issued = yield* tickets.issue(scope) + + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true) + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different request", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) + + expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) + }), + ) + + itExpiring.live("rejects tickets after the TTL elapses", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID }) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))) + + expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different workspace", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const workspaceID = WorkspaceID.ascending() + const issued = yield* tickets.issue({ ptyID, workspaceID }) + + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index d44f41f1aa..4e2c8ef9bb 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,39 +1,65 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray; tool?: Question.Tool }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) +const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) -const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) +const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Question.Tool +}) { + const question = yield* Question.Service + return yield* question.ask(input) +}) -const reply = (input: { requestID: QuestionID; answers: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input))) +const listEffect = Question.Service.use((svc) => svc.list()) -const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) +const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: { + requestID: QuestionID + answers: ReadonlyArray +}) { + const question = yield* Question.Service + yield* question.reply(input) +}) + +const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) { + const question = yield* Question.Service + yield* question.reject(id) +}) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ -async function rejectAll() { - const pending = await list() - for (const req of pending) { - await reject(req.id) - } -} +const rejectAll = Effect.gen(function* () { + yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true }) +}) -test("ask - returns pending promise", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +const waitForPending = (count: number) => + Effect.gen(function* () { + for (let i = 0; i < 100; i++) { + const pending = yield* listEffect + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) + }) + +it.instance( + "ask - remains pending until answered", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -45,19 +71,19 @@ test("ask - returns pending promise", async () => { ], }, ], - }) - expect(promise).toBeInstanceOf(Promise) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + }).pipe(Effect.forkScoped) -test("ask - adds to pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance( + "ask - adds to pending list", + () => + Effect.gen(function* () { const questions = [ { question: "What would you like to do?", @@ -69,27 +95,26 @@ test("ask - adds to pending list", async () => { }, ] - const promise = ask({ + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions, - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) expect(pending.length).toBe(1) expect(pending[0].questions).toEqual(questions) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) // reply tests -test("reply - resolves the pending ask with answers", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { +it.instance( + "reply - resolves the pending ask with answers", + () => + Effect.gen(function* () { const questions = [ { question: "What would you like to do?", @@ -101,31 +126,29 @@ test("reply - resolves the pending ask with answers", async () => { }, ] - const promise = ask({ + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions, - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) const requestID = pending[0].id - await reply({ + yield* replyEffect({ requestID, answers: [["Option 1"]], }) - const answers = await promise - expect(answers).toEqual([["Option 1"]]) - }, - }) -}) + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), + { git: true }, +) -test("reply - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +it.instance( + "reply - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -137,45 +160,40 @@ test("reply - removes from pending list", async () => { ], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) expect(pending.length).toBe(1) - await reply({ + yield* replyEffect({ requestID: pending[0].id, answers: [["Option 1"]], }) - await promise + yield* Fiber.join(fiber) - const after = await list() + const after = yield* listEffect expect(after.length).toBe(0) - }, - }) -}) + }), + { git: true }, +) -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reply({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }) - // Should not throw - }, - }) -}) +it.instance( + "reply - does nothing for unknown requestID", + () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), + { git: true }, +) // reject tests -test("reject - throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +it.instance( + "reject - throws RejectedError", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -187,22 +205,23 @@ test("reject - throws RejectedError", async () => { ], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() - await reject(pending[0].id) + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) - await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) - }, - }) -}) + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), + { git: true }, +) -test("reject - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ +it.instance( + "reject - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions: [ { @@ -214,38 +233,30 @@ test("reject - removes from pending list", async () => { ], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) expect(pending.length).toBe(1) - await reject(pending[0].id) - promise.catch(() => {}) // Ignore rejection + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - const after = await list() + const after = yield* listEffect expect(after.length).toBe(0) - }, - }) -}) + }), + { git: true }, +) -test("reject - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reject(QuestionID.make("que_unknown")) - // Should not throw - }, - }) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { + git: true, }) // multiple questions tests -test("ask - handles multiple questions", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { +it.instance( + "ask - handles multiple questions", + () => + Effect.gen(function* () { const questions = [ { question: "What would you like to do?", @@ -265,32 +276,30 @@ test("ask - handles multiple questions", async () => { }, ] - const promise = ask({ + const fiber = yield* askEffect({ sessionID: SessionID.make("ses_test"), questions, - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(1) - await reply({ + yield* replyEffect({ requestID: pending[0].id, answers: [["Build"], ["Dev"]], }) - const answers = await promise - expect(answers).toEqual([["Build"], ["Dev"]]) - }, - }) -}) + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), + { git: true }, +) // list tests -test("list - returns all pending requests", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const p1 = ask({ +it.instance( + "list - returns all pending requests", + () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ sessionID: SessionID.make("ses_test1"), questions: [ { @@ -299,9 +308,9 @@ test("list - returns all pending requests", async () => { options: [{ label: "A", description: "A" }], }, ], - }) + }).pipe(Effect.forkScoped) - const p2 = ask({ + const fiber2 = yield* askEffect({ sessionID: SessionID.make("ses_test2"), questions: [ { @@ -310,155 +319,114 @@ test("list - returns all pending requests", async () => { options: [{ label: "B", description: "B" }], }, ], - }) + }).pipe(Effect.forkScoped) - const pending = await list() + const pending = yield* waitForPending(2) expect(pending.length).toBe(2) - await rejectAll() - p1.catch(() => {}) - p2.catch(() => {}) - }, - }) -}) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), + { git: true }, +) -test("list - returns empty when no pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await list() +it.instance( + "list - returns empty when no pending", + () => + Effect.gen(function* () { + const pending = yield* listEffect expect(pending.length).toBe(0) - }, - }) -}) + }), + { git: true }, +) -test("questions stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) +it.live("questions stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) - const p1 = Instance.provide({ - directory: one.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_one"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }), - }) + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(provideInstance(one), Effect.forkScoped) - const p2 = Instance.provide({ - directory: two.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_two"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }), - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(provideInstance(two), Effect.forkScoped) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => list(), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => list(), - }) + const onePending = yield* waitForPending(1).pipe(provideInstance(one)) + const twoPending = yield* waitForPending(1).pipe(provideInstance(two)) - expect(onePending.length).toBe(1) - expect(twoPending.length).toBe(1) - expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) - expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) - await Instance.provide({ - directory: one.path, - fn: () => reject(onePending[0].id), - }) - await Instance.provide({ - directory: two.path, - fn: () => reject(twoPending[0].id), - }) + yield* rejectEffect(onePending[0].id).pipe(provideInstance(one)) + yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two)) - await p1.catch(() => {}) - await p2.catch(() => {}) -}) + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), +) -test("pending question rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("pending question rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_dispose"), - questions: [ - { - question: "Dispose me?", - header: "Dispose", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await Instance.dispose() - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) - expect(await result).toBeInstanceOf(Question.RejectedError) -}) +it.live("pending question rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) -test("pending question rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_reload"), - questions: [ - { - question: "Reload me?", - header: "Reload", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await Instance.reload({ directory: tmp.path }) - }, - }) - - expect(await result).toBeInstanceOf(Question.RejectedError) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts new file mode 100644 index 0000000000..1278e8c72e --- /dev/null +++ b/packages/opencode/test/server/auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Option, Redacted } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "../../src/server/auth" + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME +}) + +describe("ServerAuth", () => { + test("does not emit auth headers without a password", () => { + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.header()).toBeUndefined() + expect(ServerAuth.headers()).toBeUndefined() + }) + + test("defaults to the opencode username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = undefined + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }) + }) + + test("uses the configured username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + }) + + test("prefers explicit credentials", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({ + Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`, + }) + }) + + test("validates decoded credentials against effect config", () => { + const config = { password: Option.some("secret"), username: "alice" } + + expect(ServerAuth.required(config)).toBe(true) + expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true) + expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false) + }) +}) diff --git a/packages/opencode/test/server/global-bus.ts b/packages/opencode/test/server/global-bus.ts new file mode 100644 index 0000000000..c8d0f92191 --- /dev/null +++ b/packages/opencode/test/server/global-bus.ts @@ -0,0 +1,34 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Cause, Effect } from "effect" + +export function waitGlobalBusEvent(input: { + timeout?: number + message?: string + predicate: (event: GlobalEvent) => boolean +}) { + return Effect.callback((resume) => { + const cleanup = () => GlobalBus.off("event", handler) + + const handler = (event: GlobalEvent) => { + try { + if (!input.predicate(event)) return + cleanup() + resume(Effect.succeed(event)) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(cleanup) + }).pipe( + Effect.timeout(input.timeout ?? 10_000), + Effect.mapError((error) => + Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error, + ), + ) +} + +export const waitGlobalBusEventPromise = (input: Parameters[0]) => + Effect.runPromise(waitGlobalBusEvent(input)) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index a5ab7b8f36..9368089511 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import z from "zod" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -28,11 +29,11 @@ describe("session.listGlobal", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) - const firstSession = await Instance.provide({ + const firstSession = await WithInstance.provide({ directory: first.path, fn: async () => svc.create({ title: "first-session" }), }) - const secondSession = await Instance.provide({ + const secondSession = await WithInstance.provide({ directory: second.path, fn: async () => svc.create({ title: "second-session" }), }) @@ -58,12 +59,12 @@ describe("session.listGlobal", () => { test("excludes archived sessions by default", async () => { await using tmp = await tmpdir({ git: true }) - const archived = await Instance.provide({ + const archived = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "archived-session" }), }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), }) @@ -82,12 +83,12 @@ describe("session.listGlobal", () => { test("supports cursor pagination", async () => { await using tmp = await tmpdir({ git: true }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index c3bab23ac7..850098926a 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -2,12 +2,9 @@ import { NodeHttpServer } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" -import { - Authorization, - ServerAuthConfig, - authorizationLayer, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi" +import { ServerAuth } from "../../src/server/auth" +import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -16,27 +13,34 @@ const Api = HttpApi.make("test-authorization").add( HttpApiEndpoint.get("probe", "/probe", { success: Schema.String, }), + HttpApiEndpoint.get("missing", "/missing", { + success: Schema.String, + error: HttpApiError.NotFound, + }), ) .middleware(Authorization), ) -const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok"))) +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => + handlers + .handle("probe", () => Effect.succeed("ok")) + .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))), +) const apiLayer = HttpRouter.serve( HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" }) -const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" }) -const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) +const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) -const basic = (username: string, password: string) => - `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? "" const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") @@ -93,6 +97,35 @@ describe("HttpApi authorization middleware", () => { }), ) + itSecret.live("prefers auth token query credentials over basic auth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get( + `/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`, + ).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("preserves handler errors when basic auth succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get("/missing").pipe( + HttpClientRequest.setHeader("authorization", basic("opencode", "secret")), + HttpClient.execute, + ) + + expect(response.status).toBe(404) + }), + ) + + itSecret.live("preserves handler errors when auth token query succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(404) + }), + ) + itSecret.live("rejects malformed auth token query credentials", () => Effect.gen(function* () { const response = yield* HttpClient.get("/probe?auth_token=not-base64") diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 9343326738..615899f2b4 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -12,7 +12,7 @@ import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -119,7 +119,23 @@ type RequestBody = { function parameterKey(param: unknown): string | undefined { if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined if (typeof param.in !== "string" || typeof param.name !== "string") return undefined - return `${param.in}:${param.name}:${"required" in param && param.required === true}` + return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( + "schema" in param ? param.schema : undefined, + )}` +} + +function stableSchema(input: unknown): string { + return JSON.stringify(sortSchema(input)) +} + +function sortSchema(input: unknown): unknown { + if (Array.isArray(input)) return input.map(sortSchema) + if (!input || typeof input !== "object") return input + return Object.fromEntries( + Object.entries(input) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, sortSchema(value)]), + ) } function parameterSchema(input: { @@ -192,7 +208,7 @@ afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -206,15 +222,22 @@ describe("HttpApi server", () => { }) test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { - const honoRoutes = openApiRouteKeys(await Server.openapi()) + const honoRoutes = openApiRouteKeys(await Server.openapiHono()) const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ + "GET /api/session", + "GET /api/session/{sessionID}/context", + "GET /api/session/{sessionID}/message", + "POST /api/session/{sessionID}/compact", + "POST /api/session/{sessionID}/prompt", + "POST /api/session/{sessionID}/wait", + ]) }) test("matches generated OpenAPI route parameters", async () => { - const hono = openApiParameters(await Server.openapi()) + const hono = openApiParameters(await Server.openapiHono()) const effect = openApiParameters(effectOpenApi()) expect( @@ -225,7 +248,7 @@ describe("HttpApi server", () => { }) test("matches generated OpenAPI request body shape", async () => { - const hono = openApiRequestBodies(await Server.openapi()) + const hono = openApiRequestBodies(await Server.openapiHono()) const effect = openApiRequestBodies(effectOpenApi()) expect( @@ -258,6 +281,18 @@ describe("HttpApi server", () => { }) }) + test("matches SDK-affecting request schema details", () => { + const effect = effectOpenApi() + const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody + const sessionUpdateSchema = + typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate + ? sessionUpdate.content?.["application/json"]?.schema + : undefined + const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined + const time = sessionUpdateProperties?.time + expect(time?.properties?.archived).toEqual({ type: "number" }) + }) + test("documents event routes as server-sent events", () => { const effect = effectOpenApi() diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 9469a66fd5..16e8975ea1 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,26 +17,15 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 915d79784c..940efed9c3 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -27,9 +27,17 @@ async function readFirstChunk(response: Response) { return new TextDecoder().decode(result.value) } +async function readFirstEvent(response: Response) { + return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as { + id?: string + type: string + properties: Record + } +} + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") expect(response.headers.get("x-accel-buffering")).toBe("no") expect(response.headers.get("x-content-type-options")).toBe("nosniff") - expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) test("matches legacy first event frame", async () => { @@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => { const legacy = await app(false).request(EventPaths.event, { headers }) const effect = await app(true).request(EventPaths.event, { headers }) - expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy)) + const legacyEvent = await readFirstEvent(legacy) + const effectEvent = await readFirstEvent(effect) + expect(effectEvent.type).toBe(legacyEvent.type) + expect(effectEvent.properties).toEqual(legacyEvent.properties) }) }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index a4b0b66199..8684edf134 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" @@ -10,7 +10,8 @@ import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -31,26 +32,15 @@ function createSession(input?: Session.CreateInput) { } async function waitReady(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for worktree.ready")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for worktree.ready", + predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory, }) } afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -137,12 +127,12 @@ describe("experimental HttpApi", () => { test("serves global session list through Hono bridge", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index b7425007e1..81246eb0f0 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -6,7 +6,7 @@ import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -28,7 +28,7 @@ function request(route: string, directory: string, query?: Record { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 28945f0213..410dbe7426 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,23 +1,24 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" +import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -28,7 +29,7 @@ const testStateLayer = Layer.effectDiscard( yield* Effect.addFinalizer(() => Effect.promise(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }), ) @@ -40,6 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + InstanceLayer.layer, Project.defaultLayer, Workspace.defaultLayer, ), @@ -49,7 +51,7 @@ const instanceContextTestLayer = instanceRouterMiddleware .combine(workspaceRouterMiddleware) .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -63,7 +65,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.acquireRelease( Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) const workspace = yield* Workspace.Service return yield* workspace.create({ type: input.type, @@ -93,24 +95,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => Layer.build, ) -const waitDisposedEvent = Effect.promise( - () => - new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed") return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve({ directory: event.directory, workspace: event.workspace }) - } - - GlobalBus.on("event", onEvent) - }), -) +const waitDisposedEvent = waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", +}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) const serveDisposeProbe = () => HttpRouter.serve( diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 4f9ccc512a..b5f0805e4c 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,26 +17,15 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -117,13 +105,9 @@ describe("instance HttpApi", () => { test("serves instance dispose through Hono bridge", async () => { await using tmp = await tmpdir() - const disposed = new Promise((resolve) => { - const onEvent = (event: { directory?: string; payload: { type?: string } }) => { - if (event.payload.type !== "server.instance.disposed") return - GlobalBus.off("event", onEvent) - resolve(event.directory) - } - GlobalBus.on("event", onEvent) + const disposed = waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", }) const response = await app().request(InstancePaths.dispose, { @@ -133,6 +117,6 @@ describe("instance HttpApi", () => { expect(response.status).toBe(200) expect(await response.json()).toBe(true) - expect(await disposed).toBe(tmp.path) + expect((await disposed).directory).toBe(tmp.path) }) }) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 3d9245cd6f..61b1af6135 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -7,13 +7,13 @@ import * as Socket from "effect/unstable/socket/Socket" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" // Flip the experimental HttpApi flag so backend selection telemetry on the // production routes reports the right backend, and reset the database around // the test so per-instance state does not leak between runs. resetDatabase() -// already calls Instance.disposeAll(), so we don't repeat it. +// already calls disposeAllInstances(), so we don't repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index 0465b1cf6f..656541be71 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -15,7 +15,7 @@ import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -89,7 +89,7 @@ function expectJsonParity(input: { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts new file mode 100644 index 0000000000..b49fbe98b5 --- /dev/null +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -0,0 +1,319 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, +} +const auth = { username: "opencode", password: "listen-secret" } +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword + if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = original.envUsername + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" + Flag.OPENCODE_SERVER_PASSWORD = auth.password + Flag.OPENCODE_SERVER_USERNAME = auth.username + process.env.OPENCODE_SERVER_PASSWORD = auth.password + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = auth.username + delete process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +function authorization() { + return `Basic ${btoa(`${auth.username}:${auth.password}`)}` +} + +function socketURL(listener: Awaited>, id: string, dir: string, ticket?: string) { + const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) + url.protocol = "ws:" + url.searchParams.set("directory", dir) + url.searchParams.set("cursor", "-1") + if (ticket) url.searchParams.set("ticket", ticket) + return url +} + +async function requestTicket( + listener: Awaited>, + id: string, + dir: string, + options?: { ticketHeader?: boolean; origin?: string }, +) { + const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + ...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }), + ...(options?.origin ? { origin: options.origin } : {}), + }, + }) + + return response +} + +async function connectTicket(listener: Awaited>, id: string, dir: string) { + const response = await requestTicket(listener, id, dir) + expect(response.status).toBe(200) + return (await response.json()) as { ticket: string; expires_in: number } +} + +async function createCat(listener: Awaited>, dir: string) { + const response = await fetch(new URL(PtyPaths.create, listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { id: string } +} + +async function openSocket(url: URL) { + const ws = new WebSocket(url) + ws.binaryType = "arraybuffer" + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true }) + }), + 5_000, + "timed out waiting for websocket open", + ) + return ws +} + +async function expectSocketRejected(url: URL, init?: { headers?: Record }) { + // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that. + const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record }) => WebSocket + const ws = new Ctor(url, init) + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener( + "open", + () => { + ws.close(1000) + reject(new Error("websocket opened")) + }, + { once: true }, + ) + ws.addEventListener("error", () => resolve(), { once: true }) + ws.addEventListener("close", () => resolve(), { once: true }) + }), + 5_000, + "timed out waiting for websocket rejection", + ) +} + +function stop(listener: Awaited>, label: string) { + return withTimeout(listener.stop(true), 10_000, label) +} + +function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) { + const decoder = new TextDecoder() + let onMessage: ((event: MessageEvent) => void) | undefined + return withTimeout( + new Promise((resolve) => { + onMessage = (event: MessageEvent) => { + const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer) + if (!predicate(message)) return + resolve(message) + } + ws.addEventListener("message", onMessage) + }), + 5_000, + "timed out waiting for websocket message", + ).finally(() => { + if (onMessage) ws.removeEventListener("message", onMessage) + }) +} + +describe("HttpApi Server.listen", () => { + testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const response = await fetch(new URL(PtyPaths.shells, listener.url), { + headers: { authorization: authorization(), "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + expect(ticket.expires_in).toBeGreaterThan(0) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) + + const message = waitForMessage(ws, (message) => message.includes("ping-listen")) + ws.send("ping-listen\n") + expect(await message).toContain("ping-listen") + + await stop(listener, "timed out waiting for listener.stop(true)") + stopped = true + await withTimeout(closed, 5_000, "timed out waiting for websocket close") + expect(ws.readyState).toBe(WebSocket.CLOSED) + + const restarted = await startListener() + try { + const nextInfo = await createCat(restarted, tmp.path) + const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket)) + const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) + nextWs.send("ping-restarted\n") + expect(await nextMessage).toContain("ping-restarted") + nextWs.close(1000) + } finally { + await stop(restarted, "timed out waiting for restarted listener.stop(true)") + } + } finally { + if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) + } + }) + + testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener("hono") + try { + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) + ws.send("ping-hono-ticket\n") + expect(await message).toContain("ping-hono-ticket") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) + } + }) + + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403) + expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403) + + await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket")) + + const reusable = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket)) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket)) + ws.close(1000) + + const other = await createCat(listener, tmp.path) + const scoped = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket)) + + const crossOrigin = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), { + headers: { origin: "https://evil.example" }, + }) + } finally { + await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined) + } + }) + + // Regression for #25698 (Ope): the app's SDK call to + // `client.pty.connectToken({ ptyID })` originally omitted `directory`, so + // the server resolved the PTY in its own cwd context — where the project + // PTY isn't registered — and returned 404. The fix is to always pass + // `directory` from the app side; this test locks in two contracts: + // 1. Mint without directory cannot find a PTY registered in another dir. + // 2. Mint with the project directory succeeds; the resulting ticket + // consumes cleanly when the WS upgrade carries the same directory. + testPty("PTY connect token requires matching directory across mint and connect", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + // Mint without directory — server uses its own cwd, can't find the PTY. + const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }) + expect(ambiguous.status).toBe(404) + + // Mint with the project directory — succeeds, ticket binds to that scope. + const scoped = await fetch( + new URL( + `${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, + listener.url, + ), + { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }, + ) + expect(scoped.status).toBe(200) + const mint = (await scoped.json()) as { ticket: string } + + // Same directory on the WS upgrade → consume succeeds. + const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket)) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined) + } + }) + + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } +}) diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index 829f899605..d3ca4ae683 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => const passthroughAuthorization = Layer.succeed( Authorization, - Authorization.of({ - basic: (effect) => effect, - authToken: (effect) => effect, - }), + Authorization.of((effect) => effect), ) const passthroughInstanceContext = Layer.succeed( diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index e348866528..f442df5770 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,10 +5,12 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -57,7 +59,9 @@ function withMcpProject(self: (dir: string) => Effect.Effect) }), ) yield* Effect.addFinalizer(() => - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) @@ -76,7 +80,7 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 0000000000..6922d8c43f --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's NamedError +// envelope `{ name, data: { message } }`. HttpApi returns the typed-error +// shape `{ _tag }` instead. SDK consumers reading `error.data.message` +// see undefined. +// +// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test.todo("HttpApi 404 body matches NamedError shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 5e8ff01a0e..c45a81838a 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,10 +3,12 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -89,7 +91,9 @@ function withProviderProject(self: (dir: string) => Effect.Effect - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) @@ -98,7 +102,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index e4d22427cb..2b6284a310 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -7,7 +7,7 @@ import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" import { Config, Effect, Layer, Queue, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" @@ -63,7 +63,7 @@ const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index af373d933b..fd82e78639 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -8,7 +8,7 @@ import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { PtyID } from "../../src/pty/schema" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) @@ -49,7 +49,7 @@ async function cancelBody(response: Response) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 596ca4a5c4..ce774ccfd0 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,6 +5,7 @@ import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -15,7 +16,7 @@ import { Session as SessionNs } from "@/session/session" import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { @@ -169,7 +170,7 @@ function sessionTitles(value: unknown) { function resetState() { return Effect.promise(async () => { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) } @@ -226,7 +227,7 @@ function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => Effect.runPromise( @@ -260,7 +261,7 @@ afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 11e9d8b185..34cecd80d0 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -3,12 +3,13 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -16,11 +17,14 @@ import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { SessionMessage } from "../../src/v2/session-message" +import { Modelv2 } from "../../src/v2/model" +import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -44,7 +48,7 @@ function pathFor(path: string, params: Record) { function createSession(directory: string, input?: Session.CreateInput) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession(Session.Service.use((svc) => svc.create(input))), }), @@ -54,7 +58,7 @@ function createSession(directory: string, input?: Session.CreateInput) { function createTextMessage(directory: string, sessionID: SessionID, text: string) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession( @@ -82,7 +86,7 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string ) } -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -95,7 +99,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) return yield* Workspace.Service.use((svc) => svc.create({ type: input.type, @@ -138,7 +142,7 @@ function withTmp( afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -202,6 +206,50 @@ describe("session HttpApi", () => { { headers }, ), ).toMatchObject({ info: { id: message.info.id } }) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const message = new SessionMessage.Assistant({ + id: SessionMessage.ID.create(), + type: "assistant", + agent: "build", + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, + time: { created: DateTime.makeUnsafe(1) }, + content: [], + }) + Database.use((db) => + db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: parent.id, + type: message.type, + time_created: 1, + data: { + time: { created: 1 }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run(), + ) + }, + }), + ) + + expect( + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) + .items, + ).toMatchObject([{ type: "assistant" }]) }), ), ) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index f51a714575..b85658ea1e 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -2,12 +2,13 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -27,7 +28,7 @@ afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -38,7 +39,7 @@ describe("sync HttpApi", () => { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const info = spyOn(Log.create({ service: "server.sync" }), "info") - const session = await Instance.provide({ + const session = await WithInstance.provide({ directory: tmp.path, fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 3e844fad02..8d2670c492 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "../../src/bus/global" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" @@ -11,7 +10,8 @@ import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -23,14 +23,9 @@ function app(experimental = true) { } function nextCommandExecute() { - return new Promise((resolve) => { - const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { - if (event.payload.type !== TuiEvent.CommandExecute.type) return - GlobalBus.off("event", listener) - resolve(event.payload.properties?.command) - } - GlobalBus.on("event", listener) - }) + return waitGlobalBusEventPromise({ + predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, + }).then((event) => event.payload.properties?.command) } async function expectTrue(path: string, headers: Record, body?: unknown) { @@ -45,13 +40,13 @@ async function expectTrue(path: string, headers: Record, body?: afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) describe("tui HttpApi bridge", () => { test("documents legacy bad request responses", async () => { - const legacy = await Server.openapi() + const legacy = await Server.openapiHono() const effect = OpenApi.fromApi(TuiApi) for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 7c9739f51d..85162f6a92 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -12,12 +12,10 @@ import { HttpServerResponse, } from "effect/unstable/http" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { - ServerAuthConfig, - authorizationRouterMiddleware, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ServerAuth } from "../../src/server/auth" +import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { serveUIEffect } from "../../src/server/routes/ui" +import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) @@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), Layer.provide([ AppFileSystem.defaultLayer, input?.client ?? httpClient(new Response("ui")), @@ -186,6 +184,82 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was + // forwarded through the proxy while the proxy itself re-frames the body, + // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. + test("strips upstream transfer-encoding header from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { + fs, + client, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), + ), + ), + ), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(await response.text()).toBe("opencode") + }) + + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + let readPath: string | undefined + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/assets/app.js", + { + ...fs, + existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), + readFile: (path) => { + readPath = path + return path === "/$bunfs/root/assets/app.js" + ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "assets/app.js": "/$bunfs/root/assets/app.js" }, + ) + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), + ) + + expect(response.status).toBe(200) + expect(readPath).toBe("/$bunfs/root/assets/app.js") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('embedded')") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true @@ -201,6 +275,7 @@ describe("HttpApi UI fallback", () => { const response = await uiApp({ password: "secret", username: "opencode" }).request("/") expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') }) test("accepts auth token for the web UI", async () => { @@ -228,6 +303,25 @@ describe("HttpApi UI fallback", () => { expect(response.status).toBe(200) }) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and + // its icons via flows that don't carry app-managed credentials (the + // `` request is not under page-auth control), so the + // server returning 401 breaks PWA install. These specific public assets + // should bypass auth. + test("serves the PWA manifest without auth even when a server password is set", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }) + test("allows web UI preflight without auth", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 5d92635fbc..b0b276841d 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -15,9 +15,9 @@ import * as Socket from "effect/unstable/socket/Socket" import Http from "node:http" import { mkdir } from "node:fs/promises" import path from "node:path" -import { registerAdaptor } from "../../src/control-plane/adaptors" +import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { Project } from "../../src/project/project" @@ -82,7 +82,7 @@ const listenAdditionalServer = (handler: TestHandler) => return HttpServer.formatAddress(server.address) }) -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -93,7 +93,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ target: () => ({ type: "local" as const, directory }), }) -const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({ +const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({ name: "Remote Test", description: "Create a remote test workspace", configure: (info) => ({ ...info, name: "remote-test", directory }), @@ -116,10 +116,10 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { return undefined } -const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) => +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) => Effect.acquireRelease( Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, input.adaptor) + registerAdapter(input.projectID, input.type, input.adapter) const workspace = yield* Workspace.Service return yield* workspace.create({ type: input.type, @@ -144,14 +144,14 @@ const createRemoteWorkspace = (input: { createWorkspace({ projectID: input.projectID, type: input.type, - adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers), + adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers), }) const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => createWorkspace({ projectID: input.projectID, type: input.type, - adaptor: localAdaptor(input.directory), + adapter: localAdapter(input.directory), }) const insertRemoteWorkspaceWithoutSync = (input: { @@ -162,7 +162,7 @@ const insertRemoteWorkspaceWithoutSync = (input: { }) => Effect.sync(() => { const id = WorkspaceID.ascending() - registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url)) + registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) return id }) @@ -237,7 +237,7 @@ describe("HttpApi workspace routing middleware", () => { { status: 201, headers: { "x-remote": "yes" } }, ) }) - // The adaptor target tells the middleware where to proxy selected remote + // The adapter target tells the middleware where to proxy selected remote // workspace requests. Appending /probe to this base should produce // `${remoteUrl}/base/probe` on the fake remote server above. const workspace = yield* createRemoteWorkspace({ diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 6a04833e35..21bf4120c9 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -4,15 +4,15 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" @@ -27,16 +27,16 @@ const it = testEffect( Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), ) -function request(path: string, directory: string, init: RequestInit = {}) { +function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) }) } -function localAdaptor(directory: string): WorkspaceAdaptor { +function localAdapter(directory: string): WorkspaceAdapter { return { name: "Local Test", description: "Create a local test workspace", @@ -60,7 +60,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } -function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor { +function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", description: "Create a remote test workspace", @@ -128,7 +128,7 @@ afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) @@ -137,14 +137,14 @@ describe("workspace HttpApi", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const [adaptors, workspaces, status] = yield* Effect.all([ - request(WorkspacePaths.adaptors, dir), + const [adapters, workspaces, status] = yield* Effect.all([ + request(WorkspacePaths.adapters, dir), request(WorkspacePaths.list, dir), request(WorkspacePaths.status, dir), ]) - expect(adaptors.status).toBe(200) - expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({ + expect(adapters.status).toBe(200) + expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", @@ -163,27 +163,24 @@ describe("workspace HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true const dir = yield* tmpdirScoped({ git: true }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor(project.project.id, "local-test", localAdaptor(path.join(dir, ".workspace"))) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), + body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) - const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { + const warped = yield* request(WorkspacePaths.warp, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({ - total: expect.any(Number), + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), }) + expect(warped.status).toBe(204) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) @@ -195,17 +192,82 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates workspace with the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + }) + }), + ) + + it.live("creates a real git worktree workspace via the builtin adapter", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + expect(workspace).toMatchObject({ type: "worktree" }) + }), + ) + + it.live("documents legacy Hono accepting the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request( + WorkspacePaths.list, + dir, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }, + false, + ) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + }) + }), + ) + it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true const dir = yield* tmpdirScoped({ git: true }) const workspaceDir = path.join(dir, ".workspace-local") const project = yield* Project.use.fromDirectory(dir) - registerAdaptor(project.project.id, "local-target", localAdaptor(workspaceDir)) + registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir)) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + body: JSON.stringify({ type: "local-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -250,17 +312,17 @@ describe("workspace HttpApi", () => { }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor( + registerAdapter( project.project.id, "remote-target", - remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { "x-target-auth": "secret", }), ) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -319,15 +381,15 @@ describe("workspace HttpApi", () => { }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor( + registerAdapter( project.project.id, "remote-session-target", - remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), ) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-session-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const session = yield* Session.Service.use((svc) => svc.create()).pipe( diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 86c52096eb..48e28aa5ac 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,14 +1,13 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { GlobalBus } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -16,6 +15,9 @@ afterEach(async () => { await resetDatabase() }) +const disposedEvents = (seen: { directory?: string; payload: { type: string } }[], dir: string) => + seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length + describe("project.initGit endpoint", () => { test("initializes git and reloads immediately", async () => { await using tmp = await tmpdir() @@ -24,8 +26,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -42,10 +42,8 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect(reloadSpy).toHaveBeenCalledTimes(1) - expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe( - true, - ) + // Reload behavior: bus emits exactly one server.instance.disposed for the directory. + expect(disposedEvents(seen, tmp.path)).toBe(1) expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false) const current = await app.request("/project/current", { @@ -69,8 +67,7 @@ describe("project.initGit endpoint", () => { ), ).toBeTruthy() } finally { - await Instance.disposeAll() - reloadSpy.mockRestore() + await disposeAllInstances() GlobalBus.off("event", fn) } }) @@ -82,8 +79,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -98,10 +93,7 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect( - seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length, - ).toBe(0) - expect(reloadSpy).toHaveBeenCalledTimes(0) + expect(disposedEvents(seen, tmp.path)).toBe(0) const current = await app.request("/project/current", { headers: { @@ -114,8 +106,7 @@ describe("project.initGit endpoint", () => { worktree: tmp.path, }) } finally { - await Instance.disposeAll() - reloadSpy.mockRestore() + await disposeAllInstances() GlobalBus.off("event", fn) } }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 843986ba8c..1ccc9bc8e6 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -25,13 +26,13 @@ const svc = { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() }) describe("session action routes", () => { test("abort route returns success", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index e2f92c20f6..20478dde84 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" @@ -30,7 +31,7 @@ const svc = { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() }) describe("session.list", () => { @@ -40,20 +41,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -73,20 +74,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -106,22 +107,22 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "current" }), }) - const deeper = await Instance.provide({ + const deeper = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), fn: async () => svc.create({ title: "deeper" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -146,14 +147,14 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "legacy-current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "legacy-sibling" }), }) @@ -175,7 +176,7 @@ describe("session.list", () => { test("filters root sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root-session" }) @@ -192,7 +193,7 @@ describe("session.list", () => { test("filters by start time", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "new-session" }) @@ -206,7 +207,7 @@ describe("session.list", () => { test("filters by search term", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "unique-search-term-abc" }) @@ -223,7 +224,7 @@ describe("session.list", () => { test("respects limit parameter", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "session-1" }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e64d28bb44..e3c5e83136 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,12 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -31,7 +32,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function withoutWatcher(fn: () => Promise) { @@ -76,7 +77,7 @@ describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -105,7 +106,7 @@ describe("session messages endpoint", () => { test("keeps full-history responses when limit is omitted", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -126,7 +127,7 @@ describe("session messages endpoint", () => { test("rejects invalid cursors and missing sessions", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -147,7 +148,7 @@ describe("session messages endpoint", () => { test("does not truncate large legacy limit requests", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 278fba94dc..13edca1458 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -4,8 +4,9 @@ import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -24,13 +25,13 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -56,7 +57,7 @@ describe("tui.selectSession endpoint", () => { test("should return 404 when session does not exist", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -78,7 +79,7 @@ describe("tui.selectSession endpoint", () => { test("should return 400 when session ID format is invalid", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 22c44a6dff..a921ae2774 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" -import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace" +import { + isLocalWorkspaceRoute, + getWorkspaceRouteSessionID, + workspaceProxyURL, +} from "../../src/server/shared/workspace-routing" import { SessionID } from "../../src/session/schema" describe("isLocalWorkspaceRoute", () => { diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 0000000000..768a261a00 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,148 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + } + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +type TestServer = ReturnType + +function serverScoped() { + return Effect.acquireRelease( + Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })), + (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), + ) +} + +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + ) +} + +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) +} + +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = (yield* Effect.promise(() => current.json())) as { id: string } + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) + expect(updated.status).toBe(200) + }) +} + +describe("worktree endpoint reproduction", () => { + it.instance( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create", + 8_000, + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create with project start command", + 6_000, + ) + + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f35e044d7b..0d02d9918a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,6 +10,7 @@ import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -19,6 +20,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SessionV2 } from "../../src/v2/session" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -26,6 +28,7 @@ import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { TestConfig } from "../fixture/config" void Log.init({ print: false }) @@ -208,7 +211,7 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: Config.Info["compaction"]) { const base = Config.Info.zod.parse({}) - return Layer.mock(Config.Service)({ + return TestConfig.layer({ get: () => Effect.succeed({ ...base, compaction }), }) } @@ -595,6 +598,15 @@ describe("session.compaction.create", () => { auto: true, overflow: true, }) + + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( + Effect.provide(SessionV2.defaultLayer), + ) + expect(v2.at(-1)).toMatchObject({ + type: "compaction", + reason: "auto", + summary: "", + }) }), ), ) @@ -791,7 +803,7 @@ describe("session.compaction.prune", () => { describe("session.compaction.process", () => { test("throws when parent is not a user message", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -821,7 +833,7 @@ describe("session.compaction.process", () => { test("publishes compacted event on continue", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -871,7 +883,7 @@ describe("session.compaction.process", () => { test("marks summary message as errored on compact result", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -909,7 +921,7 @@ describe("session.compaction.process", () => { test("adds synthetic continue prompt when auto is enabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -950,7 +962,7 @@ describe("session.compaction.process", () => { test("persists tail_start_id for retained recent turns", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -997,7 +1009,7 @@ describe("session.compaction.process", () => { test("shrinks retained tail to fit preserve token budget", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1046,7 +1058,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1095,7 +1107,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1154,7 +1166,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1217,7 +1229,7 @@ describe("session.compaction.process", () => { test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1260,7 +1272,7 @@ describe("session.compaction.process", () => { test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1308,7 +1320,7 @@ describe("session.compaction.process", () => { test("falls back to overflow guidance when no replayable turn exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1368,7 +1380,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1442,7 +1454,7 @@ describe("session.compaction.process", () => { const ready = defer() await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1544,7 +1556,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1586,7 +1598,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1638,7 +1650,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1706,7 +1718,7 @@ describe("session.compaction.process", () => { stub.push(reply("summary one")) stub.push(reply("summary two")) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1778,7 +1790,7 @@ describe("session.compaction.process", () => { test("ignores previous summaries when sizing the retained tail", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index f800817594..3bb38c8786 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Config } from "@/config/config" -import { emptyConsoleState } from "@/config/console-state" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" @@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) -const configLayer = Layer.succeed( - Config.Service, - Config.Service.of({ - get: () => Effect.succeed({}), - getGlobal: () => Effect.succeed({}), - getConsoleState: () => Effect.succeed(emptyConsoleState), - update: () => Effect.void, - updateGlobal: (config) => Effect.succeed(config), - invalidate: () => Effect.void, - directories: () => Effect.succeed([]), - waitForDependencies: () => Effect.void, - }), -) +const configLayer = TestConfig.layer() const instructionLayer = (global: Partial) => Instruction.layer.pipe( diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index c648d62be8..7b96084832 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,6 +6,7 @@ import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@/provider/models" @@ -338,7 +339,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -425,7 +426,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -515,7 +516,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -629,7 +630,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -745,7 +746,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -864,7 +865,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -982,7 +983,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) @@ -1223,7 +1224,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 89bae246a7..a7853be0b8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -469,6 +469,13 @@ describe("session.message-v2.toModelMessage", () => { }, { ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking", + metadata: { openai: { reasoning: "meta" } }, + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a3"), type: "tool", callID: "call-1", tool: "bash", @@ -495,6 +502,7 @@ describe("session.message-v2.toModelMessage", () => { role: "assistant", content: [ { type: "text", text: "done" }, + { type: "text", text: "thinking" }, { type: "tool-call", toolCallId: "call-1", @@ -612,7 +620,7 @@ describe("session.message-v2.toModelMessage", () => { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -732,9 +740,9 @@ describe("session.message-v2.toModelMessage", () => { "12179", "4575", "", - "", + "", "User aborted the command", - "", + "", ].join("\n") const input: MessageV2.WithParts[] = [ diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 17370bbe62..35b67f7a07 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -123,7 +124,7 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai describe("MessageV2.page", () => { test("returns sync result", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -139,7 +140,7 @@ describe("MessageV2.page", () => { }) test("pages backward with opaque cursors", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -167,7 +168,7 @@ describe("MessageV2.page", () => { }) test("returns items in chronological order within a page", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -182,7 +183,7 @@ describe("MessageV2.page", () => { }) test("returns empty items for session with no messages", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -198,7 +199,7 @@ describe("MessageV2.page", () => { }) test("throws NotFoundError for non-existent session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const fake = "non-existent-session" as SessionID @@ -208,7 +209,7 @@ describe("MessageV2.page", () => { }) test("handles exact limit boundary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -225,7 +226,7 @@ describe("MessageV2.page", () => { }) test("limit of 1 returns single newest message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -242,7 +243,7 @@ describe("MessageV2.page", () => { }) test("hydrates multiple parts per message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -266,7 +267,7 @@ describe("MessageV2.page", () => { }) test("accepts cursors from fractional timestamps", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -284,7 +285,7 @@ describe("MessageV2.page", () => { }) test("messages with same timestamp are ordered by id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -304,7 +305,7 @@ describe("MessageV2.page", () => { }) test("does not return messages from other sessions", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -326,7 +327,7 @@ describe("MessageV2.page", () => { }) test("large limit returns all messages without cursor", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -346,7 +347,7 @@ describe("MessageV2.page", () => { describe("MessageV2.stream", () => { test("yields items newest first", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -361,7 +362,7 @@ describe("MessageV2.stream", () => { }) test("yields nothing for empty session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -375,7 +376,7 @@ describe("MessageV2.stream", () => { }) test("yields single message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -391,7 +392,7 @@ describe("MessageV2.stream", () => { }) test("hydrates parts for each yielded message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -409,7 +410,7 @@ describe("MessageV2.stream", () => { }) test("handles sets exceeding internal page size", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -426,7 +427,7 @@ describe("MessageV2.stream", () => { }) test("is a sync generator", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -447,7 +448,7 @@ describe("MessageV2.stream", () => { describe("MessageV2.parts", () => { test("returns parts for a message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -464,7 +465,7 @@ describe("MessageV2.parts", () => { }) test("returns empty array for message with no parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -479,7 +480,7 @@ describe("MessageV2.parts", () => { }) test("returns multiple parts in order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -512,7 +513,7 @@ describe("MessageV2.parts", () => { }) test("returns empty for non-existent message id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { await svc.create({}) @@ -523,7 +524,7 @@ describe("MessageV2.parts", () => { }) test("parts contain sessionID and messageID", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -541,7 +542,7 @@ describe("MessageV2.parts", () => { describe("MessageV2.get", () => { test("returns message with hydrated parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -560,7 +561,7 @@ describe("MessageV2.get", () => { }) test("throws NotFoundError for non-existent message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -575,7 +576,7 @@ describe("MessageV2.get", () => { }) test("scopes by session id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -593,7 +594,7 @@ describe("MessageV2.get", () => { }) test("returns message with multiple parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -616,7 +617,7 @@ describe("MessageV2.get", () => { }) test("returns assistant message with correct role", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -642,7 +643,7 @@ describe("MessageV2.get", () => { }) test("returns message with zero parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -660,7 +661,7 @@ describe("MessageV2.get", () => { describe("MessageV2.filterCompacted", () => { test("returns all messages when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -677,7 +678,7 @@ describe("MessageV2.filterCompacted", () => { }) test("stops at compaction boundary and returns chronological order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -721,7 +722,7 @@ describe("MessageV2.filterCompacted", () => { }) test("does not break on compaction part without matching summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -739,7 +740,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant with error even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -764,7 +765,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant without finish even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -785,7 +786,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains original tail when compaction stores tail_start_id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -841,7 +842,7 @@ describe("MessageV2.filterCompacted", () => { }) test("fork remaps compaction tail_start_id for filterCompacted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -907,7 +908,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains an assistant tail when compaction starts inside a turn", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -971,7 +972,7 @@ describe("MessageV2.filterCompacted", () => { }) test("prefers latest compaction boundary when repeated compactions exist", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1093,7 +1094,7 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { test("page hydration matches get for each message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1112,7 +1113,7 @@ describe("MessageV2 consistency", () => { }) test("parts from get match standalone parts call", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1128,7 +1129,7 @@ describe("MessageV2 consistency", () => { }) test("stream collects same messages as exhaustive page iteration", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("MessageV2 consistency", () => { }) test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5330569401..c5170f3464 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -19,6 +19,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" +import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -31,6 +32,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionV2 } from "../../src/v2/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -39,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" @@ -371,6 +374,47 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +it.live("prompt emits v2 prompted and synthetic events", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "hello v2" }, + { + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,bm90ZSBjb250ZW50", + }, + ], + }) + + const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( + Effect.provide(SessionV2.layer), + ) + const row = Database.use((db) => + db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(), + ) + expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) + expect(typeof row?.data.time.created).toBe("number") + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), + expect.objectContaining({ type: "synthetic", text: "note content" }), + ]), + ) + }), + { git: true, config: providerCfg }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { @@ -814,6 +858,43 @@ it.live( 30_000, ) +it.live( + "cancel propagates from slash command subtask to child session", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined + expect(typeof sessionID).toBe("string") + if (typeof sessionID !== "string") throw new Error("missing child session id") + const childID = SessionID.make(sessionID) + expect((yield* status.get(childID)).type).toBe("busy") + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + expect((yield* status.get(chat.id)).type).toBe("idle") + expect((yield* status.get(childID)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "cancel with queued callers resolves all cleanly", () => diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts deleted file mode 100644 index defce40c14..0000000000 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ /dev/null @@ -1,916 +0,0 @@ -import { describe, expect, test } from "bun:test" -import * as DateTime from "effect/DateTime" -import * as FastCheck from "effect/testing/FastCheck" -import { SessionEntry } from "../../src/v2/session-entry" -import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" -import { SessionEvent } from "../../src/v2/session-event" - -const time = (n: number) => DateTime.makeUnsafe(n) - -const word = FastCheck.string({ minLength: 1, maxLength: 8 }) -const text = FastCheck.string({ maxLength: 16 }) -const texts = FastCheck.array(text, { maxLength: 8 }) -const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 })) -const dict = FastCheck.dictionary(word, val, { maxKeys: 4 }) -const files = FastCheck.array( - word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })), - { maxLength: 2 }, -) - -function maybe(arb: FastCheck.Arbitrary) { - return FastCheck.oneof(FastCheck.constant(undefined), arb) -} - -function assistant() { - return new SessionEntry.Assistant({ - id: SessionEvent.ID.create(), - type: "assistant", - time: { created: time(0) }, - content: [], - retries: [], - }) -} - -function retryError(message: string) { - return new SessionEvent.RetryError({ - message, - isRetryable: true, - }) -} - -function retry(attempt: number, message: string, created: number) { - return new SessionEntry.AssistantRetry({ - attempt, - error: retryError(message), - time: { - created: time(created), - }, - }) -} - -function memoryState() { - const state: SessionEntryStepper.MemoryState = { - entries: [], - pending: [], - } - return state -} - -function active() { - const state: SessionEntryStepper.MemoryState = { - entries: [assistant()], - pending: [], - } - return state -} - -function run(events: SessionEvent.Event[], state = memoryState()) { - return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) -} - -function last(state: SessionEntryStepper.MemoryState) { - const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") - expect(entry?.type).toBe("assistant") - return entry?.type === "assistant" ? entry : undefined -} - -function texts_of(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") -} - -function reasons(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") -} - -function tools(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") -} - -function tool(state: SessionEntryStepper.MemoryState, callID: string) { - return tools(state).find((x) => x.callID === callID) -} - -function retriesOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.retries ?? [] -} - -function adapterStore() { - return { - committed: [] as SessionEntry.Entry[], - deferred: [] as SessionEntry.Entry[], - } -} - -function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { - const activeAssistantIndex = () => - store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - const getCurrentAssistant = () => { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = store.committed[index] - return assistant?.type === "assistant" ? assistant : undefined - } - - return { - getCurrentAssistant, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = store.committed[index] - if (current?.type !== "assistant") return - store.committed[index] = assistant - }, - appendEntry(entry) { - store.committed.push(entry) - }, - appendPending(entry) { - store.deferred.push(entry) - }, - finish() { - return store - }, - } -} - -describe("session-entry-stepper", () => { - describe("stepWith", () => { - test("reduces through a custom adapter", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), - ) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(7), - }), - ) - - expect(store.deferred).toHaveLength(1) - expect(store.deferred[0]?.type).toBe("user") - expect(store.committed).toHaveLength(1) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].content).toEqual([ - { type: "reasoning", text: "thought" }, - { type: "text", text: "world" }, - ]) - expect(store.committed[0].time.completed).toEqual(time(7)) - }) - - test("aggregates retry events onto the current assistant", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ) - - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - - describe("memory", () => { - test("tracks and replaces the current assistant", () => { - const state = active() - const adapter = SessionEntryStepper.memory(state) - const current = adapter.getCurrentAssistant() - - expect(current?.type).toBe("assistant") - if (!current) return - - adapter.updateAssistant( - new SessionEntry.Assistant({ - ...current, - content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], - time: { - ...current.time, - completed: time(1), - }, - }), - ) - - expect(adapter.getCurrentAssistant()).toBeUndefined() - expect(state.entries[0]?.type).toBe("assistant") - if (state.entries[0]?.type !== "assistant") return - - expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) - expect(state.entries[0].time.completed).toEqual(time(1)) - }) - - test("appends committed and pending entries", () => { - const state = memoryState() - const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent( - SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), - ) - const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) - - adapter.appendEntry(committed) - adapter.appendPending(pending) - - expect(state.entries).toEqual([committed]) - expect(state.pending).toEqual([pending]) - }) - - test("stepWith through memory records reasoning", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), - ) - - expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) - }) - - test("stepWith through memory records retries", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) - }) - }) - - describe("step", () => { - describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - active(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step( - state, - SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), - ), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) - - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, - ) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(parts.length + 2), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(parts.length + 3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(parts.length + 2)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) - expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, - ) - }) - - test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, - ) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, - ) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) - }) - }) - - describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(reason.length + 7), - }), - ]) - - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return - - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - ], - active(), - ) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) - - test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, - ) - }) - - test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, - ) - }) - - test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, - ) - }) - }) - }) -}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 99f20b44dc..bb69e459bc 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import { Bus } from "../../src/bus" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -34,7 +35,7 @@ function updatePart(part: T) { describe("session.created event", () => { test("should emit session.created event when session is created", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { let eventReceived = false @@ -63,7 +64,7 @@ describe("session.created event", () => { }) test("session.created event should be emitted before session.updated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const events: string[] = [] @@ -95,7 +96,7 @@ describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const info = await create({}) @@ -166,7 +167,7 @@ describe("Session", () => { test("remove works without an instance", async () => { await using tmp = await tmpdir({ git: true }) - const info = await Instance.provide({ + const info = await WithInstance.provide({ directory: tmp.path, fn: () => create({ title: "remove-without-instance" }), }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index bdf95caed5..da2ffb7937 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -5,6 +5,7 @@ import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" const projectRoot = path.join(__dirname, "../..") @@ -15,7 +16,7 @@ const hasApiKey = !!process.env.ANTHROPIC_API_KEY // Helper to run test within Instance context async function withInstance(fn: () => Promise): Promise { - return Instance.provide({ + return WithInstance.provide({ directory: projectRoot, fn, }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b85d570dc5..99ddfe72d4 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,8 +5,9 @@ import path from "path" import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -14,7 +15,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function bootstrap() { @@ -47,7 +48,7 @@ function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effe test("tracks deleted files correctly", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -62,7 +63,7 @@ test("tracks deleted files correctly", async () => { test("revert should remove new files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -86,7 +87,7 @@ test("revert should remove new files", async () => { test("revert in subdirectory", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -113,7 +114,7 @@ test("revert in subdirectory", async () => { test("multiple file operations", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -145,7 +146,7 @@ test("multiple file operations", async () => { test("empty directory handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -160,7 +161,7 @@ test("empty directory handling", async () => { test("binary file handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -184,7 +185,7 @@ test("binary file handling", async () => { test("symlink handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -199,7 +200,7 @@ test("symlink handling", async () => { test("file under size limit handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -214,7 +215,7 @@ test("file under size limit handling", async () => { test("large added files are skipped", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -231,7 +232,7 @@ test("large added files are skipped", async () => { test("nested directory revert", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -256,7 +257,7 @@ test("nested directory revert", async () => { test("special characters in filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -276,7 +277,7 @@ test("special characters in filenames", async () => { test("revert with empty patches", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should not crash with empty patches @@ -290,7 +291,7 @@ test("revert with empty patches", async () => { test("patch with invalid hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -309,7 +310,7 @@ test("patch with invalid hash", async () => { test("revert non-existent file", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -333,7 +334,7 @@ test("revert non-existent file", async () => { test("unicode filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -373,7 +374,7 @@ test("unicode filenames", async () => { test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const chineseFile = fwd(tmp.path, "文件.txt") @@ -402,7 +403,7 @@ test.skip("unicode filenames modification and restore", async () => { test("unicode filenames in subdirectories", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -428,7 +429,7 @@ test("unicode filenames in subdirectories", async () => { test("very long filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -455,7 +456,7 @@ test("very long filenames", async () => { test("hidden files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -475,7 +476,7 @@ test("hidden files", async () => { test("nested symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -495,7 +496,7 @@ test("nested symlinks", async () => { test("file permissions and ownership changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -516,7 +517,7 @@ test("file permissions and ownership changes", async () => { test("circular symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -547,7 +548,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -576,7 +577,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" test("gitignore changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -600,7 +601,7 @@ test("gitignore changes", async () => { test("files tracked in snapshot but now gitignored are filtered out", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // First, create a file and snapshot it @@ -634,7 +635,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () = test("gitignore updated between track calls filters from diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // a.txt is already committed from bootstrap - track it in snapshot @@ -669,7 +670,7 @@ test("gitignore updated between track calls filters from diff", async () => { test("git info exclude changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -695,7 +696,7 @@ test("git info exclude changes", async () => { test("git info exclude keeps global excludes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const global = `${tmp.path}/global.ignore` @@ -731,7 +732,7 @@ test("git info exclude keeps global excludes", async () => { test("concurrent file operations during patch", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -763,7 +764,7 @@ test("snapshot state isolation between projects", async () => { await using tmp1 = await bootstrap() await using tmp2 = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp1.path, fn: async () => { const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) @@ -773,7 +774,7 @@ test("snapshot state isolation between projects", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp2.path, fn: async () => { const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) @@ -793,14 +794,14 @@ test("patch detects changes in secondary worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -825,7 +826,7 @@ test("revert only removes files in invoking worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() @@ -834,7 +835,7 @@ test("revert only removes files in invoking worktree", async () => { const primaryFile = `${tmp.path}/worktree.txt` await Filesystem.write(primaryFile, "primary content") - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -869,14 +870,14 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -903,7 +904,7 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( test("track with no changes returns same hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -922,7 +923,7 @@ test("track with no changes returns same hash", async () => { test("diff function with various changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -943,7 +944,7 @@ test("diff function with various changes", async () => { test("restore function", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -977,7 +978,7 @@ test("restore function", async () => { test("revert should not delete files that existed but were deleted in snapshot", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1007,7 +1008,7 @@ test("revert should not delete files that existed but were deleted in snapshot", test("revert preserves file that existed in snapshot when deleted then recreated", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/existing.txt`, "original content") @@ -1044,7 +1045,7 @@ test("revert preserves file that existed in snapshot when deleted then recreated test("diffFull sets status based on git change type", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") @@ -1090,7 +1091,7 @@ test("diffFull sets status based on git change type", async () => { test("diffFull with new file additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1115,7 +1116,7 @@ test("diffFull with new file additions", async () => { test("diffFull with a large interleaved mixed diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) @@ -1178,7 +1179,7 @@ test("diffFull with a large interleaved mixed diff", async () => { test("diffFull preserves git diff order across batch boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) @@ -1204,7 +1205,7 @@ test("diffFull preserves git diff order across batch boundaries", async () => { test("diffFull with file modifications", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1230,7 +1231,7 @@ test("diffFull with file modifications", async () => { test("diffFull with file deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1255,7 +1256,7 @@ test("diffFull with file deletions", async () => { test("diffFull with multiple line additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1281,7 +1282,7 @@ test("diffFull with multiple line additions", async () => { test("diffFull with addition and deletion", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1313,7 +1314,7 @@ test("diffFull with addition and deletion", async () => { test("diffFull with multiple additions and deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1355,7 +1356,7 @@ test("diffFull with multiple additions and deletions", async () => { test("diffFull with no changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1372,7 +1373,7 @@ test("diffFull with no changes", async () => { test("diffFull with binary file changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1395,7 +1396,7 @@ test("diffFull with binary file changes", async () => { test("diffFull with whitespace changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") @@ -1419,7 +1420,7 @@ test("diffFull with whitespace changes", async () => { test("revert with overlapping files across patches uses first patch hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Write initial content and snapshot @@ -1453,7 +1454,7 @@ test("revert with overlapping files across patches uses first patch hash", async test("revert preserves patch order when the same hash appears again", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await $`mkdir -p ${tmp.path}/foo`.quiet() @@ -1490,7 +1491,7 @@ test("revert preserves patch order when the same hash appears again", async () = test("revert handles large mixed batches across chunk boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 0afbb18317..0986b39044 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" -import { EventTable } from "../../src/sync/event.sql" +import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" @@ -124,7 +124,7 @@ describe("SyncEvent", () => { yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) yield* Effect.promise(() => received) expect(events).toHaveLength(1) - expect(events[0]).toEqual({ + expect(events[0]).toMatchObject({ type: "item.created", properties: { id: "evt_1", @@ -252,5 +252,76 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claims unowned event sequence on replay with ownerID", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "owned" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) + + it.live( + "ignores replay from a different owner after sequence is claimed", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + yield* SyncEvent.use.replay( + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "ignored" }, + }, + { publish: false, ownerID: "owner-2" }, + ) + + const events = Database.use((db) => db.select().from(EventTable).all()) + const sequence = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(events).toHaveLength(1) + expect(events[0].id).toBe("evt_1") + expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c4cccc6eb5..fd24b557b3 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises" import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -97,7 +98,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const modifyPath = path.join(fixture.path, "modify.txt") @@ -149,7 +150,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -179,7 +180,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi.txt") @@ -199,7 +200,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const bom = String.fromCharCode(0xfeff) @@ -228,7 +229,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "insert_only.txt") @@ -247,7 +248,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "no_newline.txt") @@ -269,7 +270,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -292,7 +293,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -317,7 +318,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "duplicate.txt") @@ -335,7 +336,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" @@ -351,7 +352,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" @@ -365,7 +366,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const dirPath = path.join(fixture.path, "dir") @@ -382,7 +383,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" @@ -396,7 +397,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "modify.txt") @@ -414,7 +415,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = @@ -432,7 +433,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "tail.txt") @@ -450,7 +451,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "two_chunks.txt") @@ -468,7 +469,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi_ctx.txt") @@ -486,7 +487,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "eof_anchor.txt") @@ -508,7 +509,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `cat <<'EOF' @@ -529,7 +530,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `< { const target = path.join(fixture.path, "trailing_ws.txt") @@ -570,7 +571,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "leading_ws.txt") @@ -590,7 +591,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "unicode.txt") diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 01dc74bb22..23ae0e9090 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,7 +4,8 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -26,7 +27,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const runtime = ManagedRuntime.make( @@ -73,7 +74,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "newfile.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -102,7 +103,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -131,7 +132,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -156,7 +157,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "new.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -191,7 +192,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "existing.txt") await fs.writeFile(filepath, "old content here", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -220,7 +221,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -250,7 +251,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -275,7 +276,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -300,7 +301,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "actual content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -325,7 +326,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -352,7 +353,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -387,7 +388,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -413,7 +414,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -439,7 +440,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -464,7 +465,7 @@ describe("tool.edit", () => { const dirpath = path.join(tmp.path, "adir") await fs.mkdir(dirpath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -489,7 +490,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -558,7 +559,7 @@ describe("tool.edit", () => { }, }) - return await Instance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -702,7 +703,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ea1d340ce8..5914918178 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Effect } from "effect" import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -38,7 +39,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp", fn: async () => { await assertExternalDirectory(ctx) @@ -51,7 +52,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for paths inside Instance.directory", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) @@ -68,7 +69,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside/file.txt" const expected = glob(path.join(path.dirname(target), "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target) @@ -88,7 +89,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside" const expected = glob(path.join(target, "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target, { kind: "directory" }) @@ -104,7 +105,7 @@ describe("tool.assertExternalDirectory", () => { test("skips prompting when bypass=true", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) @@ -131,7 +132,7 @@ describe("tool.assertExternalDirectory", () => { .replaceAll("\\", "/") .toLowerCase() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, alt) @@ -152,7 +153,7 @@ describe("tool.assertExternalDirectory", () => { const root = path.parse(tmp.path).root const target = path.join(root, "boot.ini") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, target) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 028436d295..94f401afd8 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( @@ -33,49 +33,47 @@ const ctx = { } describe("tool.glob", () => { - it.live("matches files from a directory path", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) - yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const result = yield* glob.execute( + it.instance("matches files from a directory path", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(test.directory, "a.ts")) + expect(result.output).not.toContain(path.join(test.directory, "b.txt")) + }), + ) + + it.instance("rejects exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( { pattern: "*.ts", - path: dir, + path: file, }, ctx, ) - expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(dir, "a.ts")) - expect(result.output).not.toContain(path.join(dir, "b.txt")) - }), - ), - ) - - it.live("rejects exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "a.ts") - yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const exit = yield* glob - .execute( - { - pattern: "*.ts", - path: file, - }, - ctx, - ) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") - } - }), - ), + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), ) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index c807d12812..4b0da7c698 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { provideInstance, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" @@ -54,61 +54,58 @@ describe("tool.grep", () => { }), ) - it.live("no matches returns correct output", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "xyznonexistentpatternxyz123", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBe(0) - expect(result.output).toBe("No files found") - }), - ), + it.instance("no matches returns correct output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBe(0) + expect(result.output).toBe("No files found") + }), ) - it.live("finds matches in tmp instance", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBeGreaterThan(0) - }), - ), + it.instance("finds matches in tmp instance", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + }), ) - it.live("supports exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line2", - path: file, - }, - ctx, - ) - expect(result.metadata.matches).toBe(1) - expect(result.output).toContain(file) - expect(result.output).toContain("Line 2: line2") - }), - ), + it.instance("supports exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), ) }) diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 8a3189cab9..27623375c2 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -11,11 +11,11 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { LspTool } from "../../src/tool/lsp" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index bc42b0324b..9f6a0617ed 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" // byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" -import { Parameters as Bash } from "../../src/tool/bash" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" import { Parameters as Grep } from "../../src/tool/grep" @@ -19,6 +18,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp" import { Parameters as Plan } from "../../src/tool/plan" import { Parameters as Question } from "../../src/tool/question" import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" @@ -35,7 +35,7 @@ const accepts = (schema: Schema.Decoder, input: unknown): boolean => describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) - test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) @@ -66,20 +66,20 @@ describe("tool parameters", () => { }) }) - describe("bash", () => { + describe("shell", () => { test("accepts minimum: command + description", () => { - expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description (required by zod)", () => { - expect(accepts(Bash, { command: "ls" })).toBe(false) + test("rejects missing description", () => { + expect(accepts(Shell, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(accepts(Bash, { description: "list" })).toBe(false) + expect(accepts(Shell, { description: "list" })).toBe(false) }) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 662073a8c3..3f2cba8941 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest }) describe("tool.question", () => { - it.live("should successfully execute with valid question parameters", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite color?", - header: "Color", - options: [ - { label: "Red", description: "The color of passion" }, - { label: "Blue", description: "The color of sky" }, - ], - multiple: false, - }, - ] + it.instance("should successfully execute with valid question parameters", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Red"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Red"]] }) - const result = yield* Fiber.join(fiber) - expect(result.title).toBe("Asked 1 question") - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.title).toBe("Asked 1 question") + }), ) - it.live("should now pass with a header longer than 12 but less than 30 chars", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite animal?", - header: "This Header is Over 12", - options: [{ label: "Dog", description: "Man's best friend" }], - }, - ] + it.instance("should now pass with a header longer than 12 but less than 30 chars", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) - const result = yield* Fiber.join(fiber) - expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }), ) // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index db66787549..695d96ec2f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,13 +13,13 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { @@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => { }) describe("tool.read truncation", () => { - it.live("truncates large file by bytes and sets truncated metadata", () => + it.instance("truncates large file by bytes and sets truncated metadata", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const base = yield* load(path.join(FIXTURES_DIR, "models-api.json")) const target = 60 * 1024 const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) - yield* put(path.join(dir, "large.json"), content) + yield* put(path.join(test.directory, "large.json"), content) - const result = yield* exec(dir, { filePath: path.join(dir, "large.json") }) + const result = yield* run({ filePath: path.join(test.directory, "large.json") }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Output capped at") expect(result.output).toContain("Use offset=") }), ) - it.live("truncates by line count when limit is specified", () => + it.instance("truncates by line count when limit is specified", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - yield* put(path.join(dir, "many-lines.txt"), lines) + yield* put(path.join(test.directory, "many-lines.txt"), lines) - const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 }) + const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Showing lines 1-10 of 100") expect(result.output).toContain("Use offset=11") @@ -286,12 +286,12 @@ describe("tool.read truncation", () => { }), ) - it.live("does not truncate small file", () => + it.instance("does not truncate small file", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* put(path.join(dir, "small.txt"), "hello world") + const test = yield* TestInstance + yield* put(path.join(test.directory, "small.txt"), "hello world") - const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") }) + const result = yield* run({ filePath: path.join(test.directory, "small.txt") }) expect(result.metadata.truncated).toBe(false) expect(result.output).toContain("End of file") }), @@ -440,6 +440,24 @@ root_type Monster;` expect(result.output).toContain("table Monster") }), ) + + it.live("falls through unsupported image mime types to text", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const cases = [ + ["image.bmp", "BM text content"], + ["photo.tiff", "II text content"], + ["photo.avif", "avif text content"], + ] as const + + for (const item of cases) { + yield* put(path.join(dir, item[0]), item[1]) + const result = yield* exec(dir, { filePath: path.join(dir, item[0]) }) + expect(result.attachments).toBeUndefined() + expect(result.output).toContain(item[1]) + } + }), + ) }) describe("tool.read loaded instructions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 8e03177f88..c33981ddff 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -2,151 +2,187 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Plugin } from "@/plugin" +import { Question } from "@/question" +import { Todo } from "@/session/todo" +import { Skill } from "@/skill" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Provider } from "@/provider/provider" +import { LSP } from "@/lsp/lsp" +import { Instruction } from "@/session/instruction" +import { Bus } from "@/bus" +import { FetchHttpClient } from "effect/unstable/http" +import { Format } from "@/format" +import { Ripgrep } from "@/file/ripgrep" +import * as Truncate from "@/tool/truncate" +import { InstanceState } from "@/effect/instance-state" const node = CrossSpawnSpawner.defaultLayer +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +const registryLayer = ToolRegistry.layer.pipe( + Layer.provide(configLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(node), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), +) + +const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tool.registry", () => { - it.live("loads tools from .opencode/tool (singular)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tool (singular)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tool = path.join(opencode, "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools from .opencode/tools (plural)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tools (plural)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools with external dependencies without crashing", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + it.instance("loads tools with external dependencies without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), - ) + }, + }), + ), + ) - const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("cowsay") - }), - ), + const cowsay = path.join(opencode, "node_modules", "cowsay") + yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("cowsay") + }), ) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 88% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index 513cfa18ea..9b5c17c222 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -4,8 +4,9 @@ import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" @@ -28,9 +29,11 @@ const runtime = ManagedRuntime.make( ) function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) + return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) } +const initShell = initBash + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -68,6 +71,7 @@ const shells = (() => { })() const PS = new Set(["pwsh", "powershell"]) const ps = shells.filter((item) => PS.has(item.label)) +const cmdShell = shells.find((item) => item.label === "cmd") const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) @@ -135,12 +139,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -160,7 +164,7 @@ describe("tool.bash", () => { await using tmp = await tmpdir({ config: { shell: "fish" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -184,13 +188,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -210,10 +214,10 @@ describe("tool.bash permissions", () => { each("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -236,10 +240,10 @@ describe("tool.bash permissions", () => { test( `parses PowerShell conditionals for permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -261,11 +265,43 @@ describe("tool.bash permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -298,10 +334,10 @@ describe("tool.bash permissions", () => { await Bun.write(path.join(dir, "outside.txt"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await Effect.runPromise( @@ -331,10 +367,10 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -361,10 +397,10 @@ describe("tool.bash permissions", () => { test( `asks for nested PowerShell command permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await Effect.runPromise( @@ -393,10 +429,10 @@ describe("tool.bash permissions", () => { `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -423,10 +459,10 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -454,7 +490,7 @@ describe("tool.bash permissions", () => { `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -484,7 +520,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -518,10 +554,10 @@ describe("tool.bash permissions", () => { const prev = process.env[key] delete process.env[key] try { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -553,7 +589,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -582,7 +618,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -614,7 +650,7 @@ describe("tool.bash permissions", () => { test( `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -646,7 +682,7 @@ describe("tool.bash permissions", () => { test( `treats Set-Location like cd for permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -677,10 +713,10 @@ describe("tool.bash permissions", () => { test( `does not add nested PowerShell expressions to permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -702,9 +738,36 @@ describe("tool.bash permissions", () => { } } + if (process.platform === "win32" && cmdShell) { + test( + "asks for external_directory permission for cmd file commands [cmd]", + withShell(cmdShell, async () => { + await WithInstance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*"))) + }, + }) + }), + ) + } + each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -729,7 +792,7 @@ describe("tool.bash permissions", () => { each("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -759,7 +822,7 @@ describe("tool.bash permissions", () => { const err = new Error("stop after permission") await using outerTmp = await tmpdir() await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -795,7 +858,7 @@ describe("tool.bash permissions", () => { test( "uses Git Bash /tmp semantics for external workdir", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -827,7 +890,7 @@ describe("tool.bash permissions", () => { test( "uses Git Bash /tmp semantics for external file paths", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -864,7 +927,7 @@ describe("tool.bash permissions", () => { }, }) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -897,7 +960,7 @@ describe("tool.bash permissions", () => { await Bun.write(path.join(dir, "tmpfile"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -919,7 +982,7 @@ describe("tool.bash permissions", () => { each("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -942,10 +1005,10 @@ describe("tool.bash permissions", () => { each("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -964,10 +1027,10 @@ describe("tool.bash permissions", () => { each("matches redirects in permission pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -987,7 +1050,7 @@ describe("tool.bash permissions", () => { each("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1001,12 +1064,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash abort", () => { +describe("tool.shell abort", () => { test("preserves output when aborted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const controller = new AbortController() const collected: string[] = [] const res = await Effect.runPromise( @@ -1037,10 +1100,10 @@ describe("tool.bash abort", () => { }, 15_000) test("terminates command on timeout", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1052,17 +1115,17 @@ describe("tool.bash abort", () => { ), ) expect(result.output).toContain("started") - expect(result.output).toContain("bash tool terminated command after exceeding timeout") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") expect(result.output).toContain("retry with a larger timeout value in milliseconds") }, }) }, 15_000) test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1080,10 +1143,10 @@ describe("tool.bash abort", () => { }) test("returns non-zero exit code", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1099,7 +1162,7 @@ describe("tool.bash abort", () => { }) test("streams metadata updates progressively", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -1128,12 +1191,12 @@ describe("tool.bash abort", () => { }) }) -describe("tool.bash truncation", () => { +describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 500 const result = await Effect.runPromise( bash.execute( @@ -1152,10 +1215,10 @@ describe("tool.bash truncation", () => { }) test("truncates output exceeding byte limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const byteCount = Truncate.MAX_BYTES + 10000 const result = await Effect.runPromise( bash.execute( @@ -1174,10 +1237,10 @@ describe("tool.bash truncation", () => { }) test("does not truncate small output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1194,10 +1257,10 @@ describe("tool.bash truncation", () => { }) test("full output is saved to file when truncated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 100 const result = await Effect.runPromise( bash.execute( diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 175c1526d0..7473d2d56a 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -8,7 +8,7 @@ import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -23,7 +23,7 @@ const baseCtx: Omit = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const node = CrossSpawnSpawner.defaultLayer diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 147541f3d2..f75fcf84b8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,22 +1,21 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Instance } from "../../src/project/instance" import { Session } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ref = { @@ -35,6 +34,14 @@ const it = testEffect( ), ) +function defer() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const session = yield* Session.Service const chat = yield* session.create({ title }) @@ -66,7 +73,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { @@ -107,102 +114,270 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa } describe("tool.task", () => { - it.live("description sorts subagents by name and is stable across calls", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const get = Effect.fnUntraced(function* () { - const tools = yield* registry.tools({ ...ref, agent: build }) - return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" - }) - const first = yield* get() - const second = yield* get() + it.instance( + "description sorts subagents by name and is stable across calls", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() - expect(first).toBe(second) + expect(first).toBe(second) - const alpha = first.indexOf("- alpha: Alpha agent") - const explore = first.indexOf("- explore:") - const general = first.indexOf("- general:") - const zebra = first.indexOf("- zebra: Zebra agent") + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) - }), - { - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("description hides denied subagents for the caller", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" + it.instance( + "description hides denied subagents for the caller", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" - expect(description).toContain("- alpha: Alpha agent") - expect(description).not.toContain("- zebra: Zebra agent") - }), - { - config: { - permission: { - task: { - "*": "allow", - zebra: "deny", - }, + expect(description).toContain("- alpha: Alpha agent") + expect(description).not.toContain("- zebra: Zebra agent") + }), + { + config: { + permission: { + task: { + "*": "allow", + zebra: "deny", }, - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + }, + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("execute resumes an existing task session from task_id", () => - provideTmpdirInstance(() => + it.instance("execute resumes an existing task session from task_id", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(child.id) + expect(result.metadata.sessionId).toBe(child.id) + expect(result.output).toContain(`task_id: ${child.id}`) + expect(seen?.sessionID).toBe(child.id) + }), + ) + + it.instance("execute asks by default and skips checks when bypassed", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const exec = (extra?: Record) => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, ...extra }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + yield* exec() + yield* exec({ bypassAgentCheck: true }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + ) + + it.instance("execute cancels child session when abort signal fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ready = defer() + const cancelled = defer() + const abort = new AbortController() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.promise(() => { + ready.resolve(input) + return cancelled.promise + }).pipe(Effect.as(reply(input, "cancelled"))), + } + + const fiber = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.forkChild) + + const input = yield* Effect.promise(() => ready.promise) + abort.abort() + expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.instance("execute creates a child when task_id does not exist", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: "ses_missing", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(result.metadata.sessionId).not.toBe("ses_missing") + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + }), + ) + + it.instance( + "execute shapes child permissions for task, todowrite, and primary tools", + () => Effect.gen(function* () { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() - const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", - subagent_type: "general", - task_id: child.id, + subagent_type: "reviewer", }, { sessionID: chat.id, @@ -216,172 +391,45 @@ describe("tool.task", () => { }, ) - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(child.id) - expect(result.metadata.sessionId).toBe(child.id) - expect(result.output).toContain(`task_id: ${child.id}`) - expect(seen?.sessionID).toBe(child.id) - }), - ), - ) - - it.live("execute asks by default and skips checks when bypassed", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - const calls: unknown[] = [] - const promptOps = stubOps() - - const exec = (extra?: Record) => - def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps, ...extra }, - messages: [], - metadata: () => Effect.void, - ask: (input) => - Effect.sync(() => { - calls.push(input) - }), - }, - ) - - yield* exec() - yield* exec({ bypassAgentCheck: true }) - - expect(calls).toHaveLength(1) - expect(calls[0]).toEqual({ - permission: "task", - patterns: ["general"], - always: ["*"], - metadata: { - description: "inspect bug", - subagent_type: "general", + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.parentID).toBe(chat.id) + expect(child.permission).toEqual([ + { + permission: "todowrite", + pattern: "*", + action: "deny", }, + { + permission: "bash", + pattern: "*", + action: "allow", + }, + { + permission: "read", + pattern: "*", + action: "allow", + }, + ]) + expect(seen?.tools).toEqual({ + todowrite: false, + bash: false, + read: false, }) }), - ), - ) - - it.live("execute creates a child when task_id does not exist", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - task_id: "ses_missing", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(result.metadata.sessionId) - expect(result.metadata.sessionId).not.toBe("ses_missing") - expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) - expect(seen?.sessionID).toBe(result.metadata.sessionId) - }), - ), - ) - - it.live("execute shapes child permissions for task, todowrite, and primary tools", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "reviewer", + { + config: { + agent: { + reviewer: { + mode: "subagent", + permission: { + task: "allow", }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const child = yield* sessions.get(result.metadata.sessionId) - expect(child.parentID).toBe(chat.id) - expect(child.permission).toEqual([ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "bash", - pattern: "*", - action: "allow", - }, - { - permission: "read", - pattern: "*", - action: "allow", - }, - ]) - expect(seen?.tools).toEqual({ - todowrite: false, - bash: false, - read: false, - }) - }), - { - config: { - agent: { - reviewer: { - mode: "subagent", - permission: { - task: "allow", - }, - }, - }, - experimental: { - primary_tools: ["bash", "read"], }, }, + experimental: { + primary_tools: ["bash", "read"], + }, }, - ), + }, ) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9a01f95cd1..e948a6dcb3 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" +import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") @@ -16,11 +17,7 @@ const ROOT = path.resolve(import.meta.dir, "..", "..") const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) const configuredLayer = (cfg: Config.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }), - ) + Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, TestConfig.layer({ get: () => Effect.succeed(cfg) })) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index dbde4ed5b7..6c7f6aba77 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -41,7 +42,7 @@ describe("tool.webfetch", () => { await withFetch( () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) @@ -69,7 +70,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) @@ -89,7 +90,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "text/plain; charset=utf-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index cc9f87100c..8bba52a4b2 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -28,7 +28,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const it = testEffect( @@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* ( describe("tool.write", () => { describe("new file creation", () => { - it.live("writes content to new file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "newfile.txt") - const result = yield* run({ filePath: filepath, content: "Hello, World!" }) + it.instance("writes content to new file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, content: "Hello, World!" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(false) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(false) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("Hello, World!") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("Hello, World!") + }), ) - it.live("creates parent directories if needed", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "nested", "deep", "file.txt") - yield* run({ filePath: filepath, content: "nested content" }) + it.instance("creates parent directories if needed", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "deep", "file.txt") + yield* run({ filePath: filepath, content: "nested content" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("nested content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("nested content") + }), ) - it.live("handles relative paths by resolving to instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* run({ filePath: "relative.txt", content: "relative content" }) + it.instance("handles relative paths by resolving to instance directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* run({ filePath: "relative.txt", content: "relative content" }) - const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) - expect(content).toBe("relative content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8")) + expect(content).toBe("relative content") + }), ) }) describe("existing file overwrite", () => { - it.live("overwrites existing file content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "existing.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new content" }) + it.instance("overwrites existing file content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new content" }) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(true) + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(true) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("new content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("new content") + }), ) - it.live("preserves BOM when overwriting existing files", () => - provideTmpdirInstance((dir) => + it.instance("preserves BOM when overwriting existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ) + + it.instance( + "restores BOM after formatter strips it", + () => Effect.gen(function* () { - const filepath = path.join(dir, "existing.cs") + const test = yield* TestInstance + const filepath = path.join(test.directory, "formatted.cs") const bom = String.fromCharCode(0xfeff) yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) @@ -127,165 +140,138 @@ describe("tool.write", () => { expect(content.charCodeAt(0)).toBe(0xfeff) expect(content.slice(1)).toBe("using Up;\n") }), - ), - ) - - it.live("restores BOM after formatter strips it", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "formatted.cs") - const bom = String.fromCharCode(0xfeff) - yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) - - yield* run({ filePath: filepath, content: "using Up;\n" }) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }), - { - config: { - formatter: { - stripbom: { - extensions: [".cs"], - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", - "$FILE", - ], - }, + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], }, }, }, - ), + }, ) - it.live("returns diff in metadata for existing files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new" }) + it.instance("returns diff in metadata for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new" }) - expect(result.metadata).toHaveProperty("filepath", filepath) - expect(result.metadata).toHaveProperty("exists", true) - }), - ), + expect(result.metadata).toHaveProperty("filepath", filepath) + expect(result.metadata).toHaveProperty("exists", true) + }), ) }) describe("file permissions", () => { - it.live("sets file permissions when writing sensitive data", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "sensitive.json") - yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) + it.instance("sets file permissions when writing sensitive data", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "sensitive.json") + yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) - if (process.platform !== "win32") { - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.mode & 0o777).toBe(0o644) - } - }), - ), + if (process.platform !== "win32") { + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.mode & 0o777).toBe(0o644) + } + }), ) }) describe("content types", () => { - it.live("writes JSON content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "data.json") - const data = { key: "value", nested: { array: [1, 2, 3] } } - yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) + it.instance("writes JSON content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.json") + const data = { key: "value", nested: { array: [1, 2, 3] } } + yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(JSON.parse(content)).toEqual(data) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(JSON.parse(content)).toEqual(data) + }), ) - it.live("writes binary-safe content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "binary.bin") - const content = "Hello\x00World\x01\x02\x03" - yield* run({ filePath: filepath, content }) + it.instance("writes binary-safe content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "binary.bin") + const content = "Hello\x00World\x01\x02\x03" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) - it.live("writes empty content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "empty.txt") - yield* run({ filePath: filepath, content: "" }) + it.instance("writes empty content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "empty.txt") + yield* run({ filePath: filepath, content: "" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("") - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.size).toBe(0) - }), - ), + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.size).toBe(0) + }), ) - it.live("writes multi-line content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "multiline.txt") - const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") - yield* run({ filePath: filepath, content: lines }) + it.instance("writes multi-line content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "multiline.txt") + const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + yield* run({ filePath: filepath, content: lines }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe(lines) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe(lines) + }), ) - it.live("handles different line endings", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "crlf.txt") - const content = "Line 1\r\nLine 2\r\nLine 3" - yield* run({ filePath: filepath, content }) + it.instance("handles different line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "crlf.txt") + const content = "Line 1\r\nLine 2\r\nLine 3" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) }) describe("error handling", () => { - it.live("throws error when OS denies write access", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const readonlyPath = path.join(dir, "readonly.txt") - yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) - yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) - expect(exit._tag).toBe("Failure") - }), - ), + it.instance("throws error when OS denies write access", () => + Effect.gen(function* () { + const test = yield* TestInstance + const readonlyPath = path.join(test.directory, "readonly.txt") + yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) + yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) + const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), ) }) describe("title generation", () => { - it.live("returns relative path as title", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "src", "components", "Button.tsx") - yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) + it.instance("returns relative path as title", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "src", "components", "Button.tsx") + yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) - const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) - expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) - }), - ), + const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) + expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) + }), ) }) }) diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e536f3c4ea..e7a02d6151 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -22,6 +22,19 @@ describe("util.error", () => { expect(data.code).toBe("E_BAD") }) + test("never returns bare {} for opaque object errors", () => { + // Plain empty object — what the SDK threw before we wrapped it. + expect(errorFormat({})).not.toBe("{}") + expect(errorFormat({})).toContain("no message") + + // Object with only non-enumerable own properties (JSON.stringify drops them). + class OpaqueError {} + const opaque = new OpaqueError() + Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false }) + expect(errorFormat(opaque)).not.toBe("{}") + expect(errorFormat(opaque)).toContain("OpaqueError") + }) + test("handles opaque throwables with custom toString", () => { const err = { toString() { diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts new file mode 100644 index 0000000000..44ac031eda --- /dev/null +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -0,0 +1,216 @@ +import { expect, test } from "bun:test" +import * as DateTime from "effect/DateTime" +import { SessionID } from "../../src/session/schema" +import { EventV2 } from "../../src/v2/event" +import { Modelv2 } from "../../src/v2/model" +import { SessionEvent } from "../../src/v2/session-event" +import { SessionMessageUpdater } from "../../src/v2/session-message-updater" + +test("step snapshots carry over to assistant messages", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, + snapshot: "before", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + finish: "stop", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + snapshot: "after", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].finish).toBe("stop") +}) + +test("text ended populates assistant text content", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "hello assistant", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) +}) + +test("tool completion stores completed timestamp", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const callID = "call" + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.input.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + callID, + name: "bash", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.called", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + callID, + tool: "bash", + input: { command: "pwd" }, + provider: { executed: true, metadata: { source: "provider" } }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.success", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + callID, + structured: {}, + content: [{ type: "text", text: "/tmp" }], + provider: { executed: true, metadata: { status: "done" } }, + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content[0]?.type).toBe("tool") + if (state.messages[0].content[0]?.type !== "tool") return + expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) + expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } }) +}) + +test("compaction events reduce to compaction message", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const id = EventV2.ID.create() + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id, + type: "session.next.compaction.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "auto", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "hello ", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "summary", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + text: "final summary", + include: "recent context", + }, + } satisfies SessionEvent.Event) + + expect(state.messages).toHaveLength(1) + expect(state.messages[0]).toMatchObject({ + id, + type: "compaction", + reason: "auto", + summary: "final summary", + include: "recent context", + time: { created: DateTime.makeUnsafe(1) }, + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9082f11831..661201d2d9 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.0", - "@opentui/solid": ">=0.2.0" + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 71a3278cbb..2e96dd9801 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -45,7 +45,7 @@ export type WorkspaceTarget = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise @@ -60,7 +60,7 @@ export type PluginInput = { directory: string worktree: string experimental_workspace: { - register(type: string, adaptor: WorkspaceAdaptor): void + register(type: string, adapter: WorkspaceAdapter): void } serverUrl: URL $: BunShell diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1fbf05d5e1..bef0fee141 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index e920cc0fdb..946ad1402b 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,13 +9,15 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono" +const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") +// `bun dev generate` now derives the spec from the Effect HttpApi contract by +// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. if (openapiSource === "httpapi") { - await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode) -} else { await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +} else { + await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) } await createClient({ diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 2d71d8446d..8b49e7f101 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -84,5 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) + // The generated client falls back to throwing a literal `{}` when the server + // responds with an empty / unparseable error body, which surfaces as a bare + // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so + // downstream formatters get a useful message — but pass through any parsed + // JSON error body unchanged so existing consumers can still inspect fields. + client.interceptors.error.use((error, response, request) => { + const isEmpty = + error === undefined || + error === null || + error === "" || + (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) + if (!isEmpty) return error + const method = request?.method ?? "?" + const url = request?.url ?? "?" + if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) + const status = response.status + const statusText = response.statusText ? " " + response.statusText : "" + return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) + }) return new OpencodeClient({ client }) } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2da7c865d7..ffc0970c0e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -20,24 +20,24 @@ import type { ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, - EventTuiCommandExecute, - EventTuiPromptAppend, - EventTuiSessionSelect, - EventTuiToastShow, + EventTuiCommandExecute2, + EventTuiPromptAppend2, + EventTuiSessionSelect2, + EventTuiToastShow2, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, - ExperimentalWorkspaceAdaptorListResponses, + ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ExperimentalWorkspaceSessionRestoreResponses, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceWarpErrors, + ExperimentalWorkspaceWarpResponses, FileListResponses, FilePartInput, FilePartSource, @@ -90,6 +90,7 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + Prompt, ProviderAuthResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, @@ -98,6 +99,8 @@ import type { ProviderOauthCallbackResponses, PtyConnectErrors, PtyConnectResponses, + PtyConnectTokenErrors, + PtyConnectTokenResponses, PtyCreateErrors, PtyCreateResponses, PtyGetErrors, @@ -126,6 +129,7 @@ import type { SessionDeleteMessageErrors, SessionDeleteMessageResponses, SessionDeleteResponses, + SessionDelivery, SessionDiffResponses, SessionForkResponses, SessionGetErrors, @@ -165,6 +169,8 @@ import type { SyncReplayErrors, SyncReplayResponses, SyncStartResponses, + SyncStealErrors, + SyncStealResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -187,6 +193,14 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2SessionCompactResponses, + V2SessionContextResponses, + V2SessionListErrors, + V2SessionListResponses, + V2SessionMessagesErrors, + V2SessionMessagesResponses, + V2SessionPromptResponses, + V2SessionWaitResponses, VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, @@ -244,111 +258,6 @@ class HeyApiRegistry { } } -export class Config extends HeyApiClient { - /** - * Get global configuration - * - * Retrieve the current global OpenCode configuration settings and preferences. - */ - public get(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/config", - ...options, - }) - } - - /** - * Update global configuration - * - * Update global OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Global extends HeyApiClient { - /** - * Get health - * - * Get health information about the OpenCode server. - */ - public health(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/health", - ...options, - }) - } - - /** - * Get global events - * - * Subscribe to global events from the OpenCode system using server-sent events. - */ - public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/event", - ...options, - }) - } - - /** - * Dispose instance - * - * Clean up and dispose all OpenCode instances, releasing all resources. - */ - public dispose(options?: Options) { - return (options?.client ?? this.client).post({ - url: "/global/dispose", - ...options, - }) - } - - /** - * Upgrade opencode - * - * Upgrade opencode to the specified version or latest if not specified. - */ - public upgrade( - parameters?: { - target?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) - return (options?.client ?? this.client).post({ - url: "/global/upgrade", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _config?: Config - get config(): Config { - return (this._config ??= new Config({ client: this.client })) - } -} - export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -512,13 +421,118 @@ export class App extends HeyApiClient { } } -export class Adaptor extends HeyApiClient { +export class Config extends HeyApiClient { /** - * List workspace adaptors + * Get global configuration * - * List all available workspace adaptors for the current project. + * Retrieve the current global OpenCode configuration settings and preferences. */ - public list( + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/config", + ...options, + }) + } + + /** + * Update global configuration + * + * Update global OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) + return (options?.client ?? this.client).patch({ + url: "/global/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Global extends HeyApiClient { + /** + * Get health + * + * Get health information about the OpenCode server. + */ + public health(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/health", + ...options, + }) + } + + /** + * Get global events + * + * Subscribe to global events from the OpenCode system using server-sent events. + */ + public event(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/event", + ...options, + }) + } + + /** + * Dispose instance + * + * Clean up and dispose all OpenCode instances, releasing all resources. + */ + public dispose(options?: Options) { + return (options?.client ?? this.client).post({ + url: "/global/dispose", + ...options, + }) + } + + /** + * Upgrade opencode + * + * Upgrade opencode to the specified version or latest if not specified. + */ + public upgrade( + parameters?: { + target?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) + return (options?.client ?? this.client).post({ + url: "/global/upgrade", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _config?: Config + get config(): Config { + return (this._config ??= new Config({ client: this.client })) + } +} + +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( parameters?: { directory?: string workspace?: string @@ -536,21 +550,21 @@ export class Adaptor extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adaptor", + return (options?.client ?? this.client).sse.get({ + url: "/event", ...options, ...params, }) } } -export class Workspace extends HeyApiClient { +export class Config2 extends HeyApiClient { /** - * List workspaces + * Get configuration * - * List all workspaces. + * Retrieve the current OpenCode configuration settings and preferences. */ - public list( + public get( parameters?: { directory?: string workspace?: string @@ -568,26 +582,23 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", + return (options?.client ?? this.client).get({ + url: "/config", ...options, ...params, }) } /** - * Create workspace + * Update configuration * - * Create a workspace for the current project. + * Update OpenCode configuration settings and preferences. */ - public create( + public update( parameters?: { directory?: string workspace?: string - id?: string - type?: string - branch?: string | null - extra?: unknown | null + config?: Config3 }, options?: Options, ) { @@ -598,20 +609,13 @@ export class Workspace extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "type" }, - { in: "body", key: "branch" }, - { in: "body", key: "extra" }, + { key: "config", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceCreateErrors, - ThrowOnError - >({ - url: "/experimental/workspace", + return (options?.client ?? this.client).patch({ + url: "/config", ...options, ...params, headers: { @@ -623,11 +627,11 @@ export class Workspace extends HeyApiClient { } /** - * Workspace status + * List config providers * - * Get connection status for workspaces in the current project. + * Get a list of all configured AI providers and their default models. */ - public status( + public providers( parameters?: { directory?: string workspace?: string @@ -645,96 +649,12 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/status", + return (options?.client ?? this.client).get({ + url: "/config/providers", ...options, ...params, }) } - - /** - * Remove workspace - * - * Remove an existing workspace. - */ - public remove( - parameters: { - id: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete< - ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceRemoveErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}", - ...options, - ...params, - }) - } - - /** - * Restore session into workspace - * - * Replay a session's sync events into the target workspace in batches. - */ - public sessionRestore( - parameters: { - id: string - directory?: string - workspace?: string - sessionID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}/session-restore", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _adaptor?: Adaptor - get adaptor(): Adaptor { - return (this._adaptor ??= new Adaptor({ client: this.client })) - } } export class Console extends HeyApiClient { @@ -914,12 +834,232 @@ export class Resource extends HeyApiClient { } } -export class Experimental extends HeyApiClient { - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) +export class Adapter extends HeyApiClient { + /** + * List workspace adapters + * + * List all available workspace adapters for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adapter", + ...options, + ...params, + }) + } +} + +export class Workspace extends HeyApiClient { + /** + * List workspaces + * + * List all workspaces. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace", + ...options, + ...params, + }) } + /** + * Create workspace + * + * Create a workspace for the current project. + */ + public create( + parameters?: { + directory?: string + workspace?: string + id?: string + type?: string + branch?: string | null + extra?: unknown | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "type" }, + { in: "body", key: "branch" }, + { in: "body", key: "extra" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Workspace status + * + * Get connection status for workspaces in the current project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/status", + ...options, + ...params, + }) + } + + /** + * Remove workspace + * + * Remove an existing workspace. + */ + public remove( + parameters: { + id: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", + ...options, + ...params, + }) + } + + /** + * Warp session into workspace + * + * Move a session's sync history into the target workspace, or detach it to the local project. + */ + public warp( + parameters?: { + directory?: string + workspace?: string + id?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceWarpResponses, + ExperimentalWorkspaceWarpErrors, + ThrowOnError + >({ + url: "/experimental/workspace/warp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _adapter?: Adapter + get adapter(): Adapter { + return (this._adapter ??= new Adapter({ client: this.client })) + } +} + +export class Experimental extends HeyApiClient { private _console?: Console get console(): Console { return (this._console ??= new Console({ client: this.client })) @@ -934,6 +1074,919 @@ export class Experimental extends HeyApiClient { get resource(): Resource { return (this._resource ??= new Resource({ client: this.client })) } + + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } +} + +export class Tool extends HeyApiClient { + /** + * List tools + * + * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + */ + public list( + parameters: { + directory?: string + workspace?: string + provider: string + model: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool", + ...options, + ...params, + }) + } + + /** + * List tool IDs + * + * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. + */ + public ids( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/tool/ids", + ...options, + ...params, + }) + } +} + +export class Worktree extends HeyApiClient { + /** + * Remove worktree + * + * Remove a git worktree and delete its branch. + */ + public remove( + parameters?: { + directory?: string + workspace?: string + worktreeRemoveInput?: WorktreeRemoveInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeRemoveInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List worktrees + * + * List all sandbox worktrees for the current project. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/worktree", + ...options, + ...params, + }) + } + + /** + * Create worktree + * + * Create a new git worktree for the current project and run any configured startup scripts. + */ + public create( + parameters?: { + directory?: string + workspace?: string + worktreeCreateInput?: WorktreeCreateInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeCreateInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reset worktree + * + * Reset a worktree branch to the primary default branch. + */ + public reset( + parameters?: { + directory?: string + workspace?: string + worktreeResetInput?: WorktreeResetInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "worktreeResetInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Find extends HeyApiClient { + /** + * Find text + * + * Search for text patterns across files in the project using ripgrep. + */ + public text( + parameters: { + directory?: string + workspace?: string + pattern: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "pattern" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find", + ...options, + ...params, + }) + } + + /** + * Find files + * + * Search for files or directories by name or pattern in the project directory. + */ + public files( + parameters: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/file", + ...options, + ...params, + }) + } + + /** + * Find symbols + * + * Search for workspace symbols like functions, classes, and variables using LSP. + */ + public symbols( + parameters: { + directory?: string + workspace?: string + query: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/find/symbol", + ...options, + ...params, + }) + } +} + +export class File extends HeyApiClient { + /** + * List files + * + * List files and directories in a specified path. + */ + public list( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file", + ...options, + ...params, + }) + } + + /** + * Read file + * + * Read the content of a specified file. + */ + public read( + parameters: { + directory?: string + workspace?: string + path: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "path" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/content", + ...options, + ...params, + }) + } + + /** + * Get file status + * + * Get the git status of all files in the project. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/file/status", + ...options, + ...params, + }) + } +} + +export class Instance extends HeyApiClient { + /** + * Dispose instance + * + * Clean up and dispose the current OpenCode instance, releasing all resources. + */ + public dispose( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/instance/dispose", + ...options, + ...params, + }) + } +} + +export class Path extends HeyApiClient { + /** + * Get paths + * + * Retrieve the current working directory and related path information for the OpenCode instance. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/path", + ...options, + ...params, + }) + } +} + +export class Vcs extends HeyApiClient { + /** + * Get VCS info + * + * Retrieve version control system (VCS) information for the current project, such as git branch. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs", + ...options, + ...params, + }) + } + + /** + * Get VCS diff + * + * Retrieve the current git diff for the working tree or against the default branch. + */ + public diff( + parameters: { + directory?: string + workspace?: string + mode: "git" | "branch" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "mode" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/vcs/diff", + ...options, + ...params, + }) + } +} + +export class Command extends HeyApiClient { + /** + * List commands + * + * Get a list of all available commands in the OpenCode system. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/command", + ...options, + ...params, + }) + } +} + +export class Lsp extends HeyApiClient { + /** + * Get LSP status + * + * Get LSP server status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/lsp", + ...options, + ...params, + }) + } +} + +export class Formatter extends HeyApiClient { + /** + * Get formatter status + * + * Get formatter status + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/formatter", + ...options, + ...params, + }) + } +} + +export class Auth2 extends HeyApiClient { + /** + * Remove MCP OAuth + * + * Remove OAuth credentials for an MCP server. + */ + public remove( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Start MCP OAuth + * + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + */ + public start( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", + ...options, + ...params, + }) + } + + /** + * Complete MCP OAuth + * + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + */ + public callback( + parameters: { + name: string + directory?: string + workspace?: string + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Authenticate MCP OAuth + * + * Start OAuth flow and wait for callback (opens browser). + */ + public authenticate( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, + }, + ) + } +} + +export class Mcp extends HeyApiClient { + /** + * Get MCP status + * + * Get the status of all Model Context Protocol (MCP) servers. + */ + public status( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/mcp", + ...options, + ...params, + }) + } + + /** + * Add MCP server + * + * Dynamically add a new Model Context Protocol (MCP) server to the system. + */ + public add( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Connect an MCP server. + */ + public connect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", + ...options, + ...params, + }) + } + + /** + * Disconnect an MCP server. + */ + public disconnect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) + } + + private _auth?: Auth2 + get auth(): Auth2 { + return (this._auth ??= new Auth2({ client: this.client })) + } } export class Project extends HeyApiClient { @@ -1296,6 +2349,38 @@ export class Pty extends HeyApiClient { }) } + /** + * Create PTY WebSocket token + * + * Create a short-lived ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + /** * Connect to PTY session * @@ -1329,147 +2414,48 @@ export class Pty extends HeyApiClient { } } -export class Config2 extends HeyApiClient { +export class Question extends HeyApiClient { /** - * Get configuration + * List pending questions * - * Retrieve the current OpenCode configuration settings and preferences. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config", - ...options, - ...params, - }) - } - - /** - * Update configuration - * - * Update OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - directory?: string - workspace?: string - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "config", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).patch({ - url: "/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List config providers - * - * Get a list of all configured AI providers and their default models. - */ - public providers( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/config/providers", - ...options, - ...params, - }) - } -} - -export class Tool extends HeyApiClient { - /** - * List tool IDs - * - * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. - */ - public ids( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", - ...options, - ...params, - }) - } - - /** - * List tools - * - * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + * Get all pending question requests across all sessions. */ public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/question", + ...options, + ...params, + }) + } + + /** + * Reply to question request + * + * Provide answers to a question request from the AI assistant. + */ + public reply( parameters: { + requestID: string directory?: string workspace?: string - provider: string - model: string + answers?: Array }, options?: Options, ) { @@ -1478,50 +2464,16 @@ export class Tool extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: "body", key: "answers" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool", - ...options, - ...params, - }) - } -} - -export class Worktree extends HeyApiClient { - /** - * Remove worktree - * - * Remove a git worktree and delete its branch. - */ - public remove( - parameters?: { - directory?: string - workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reply", ...options, ...params, headers: { @@ -1533,9 +2485,43 @@ export class Worktree extends HeyApiClient { } /** - * List worktrees + * Reject question request * - * List all sandbox worktrees for the current project. + * Reject a question request from the AI assistant. + */ + public reject( + parameters: { + requestID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "requestID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reject", + ...options, + ...params, + }) + } +} + +export class Permission extends HeyApiClient { + /** + * List pending permissions + * + * Get all pending permission requests across all sessions. */ public list( parameters?: { @@ -1555,23 +2541,25 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/permission", ...options, ...params, }) } /** - * Create worktree + * Respond to permission request * - * Create a new git worktree for the current project and run any configured startup scripts. + * Approve or deny a permission request from the AI assistant. */ - public create( - parameters?: { + public reply( + parameters: { + requestID: string directory?: string workspace?: string - worktreeCreateInput?: WorktreeCreateInput + reply?: "once" | "always" | "reject" + message?: string }, options?: Options, ) { @@ -1580,15 +2568,17 @@ export class Worktree extends HeyApiClient { [ { args: [ + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", ...options, ...params, headers: { @@ -1600,15 +2590,153 @@ export class Worktree extends HeyApiClient { } /** - * Reset worktree + * Respond to permission * - * Reset a worktree branch to the primary default branch. + * Approve or deny a permission request from the AI assistant. + * + * @deprecated */ - public reset( + public respond( + parameters: { + sessionID: string + permissionID: string + directory?: string + workspace?: string + response?: "once" | "always" | "reject" + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "permissionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "response" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permissions/{permissionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Oauth extends HeyApiClient { + /** + * Start OAuth authorization + * + * Start the OAuth authorization flow for a provider. + */ + public authorize( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + inputs?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "inputs" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/authorize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Handle OAuth callback + * + * Handle the OAuth callback from a provider after user authorization. + */ + public callback( + parameters: { + providerID: string + directory?: string + workspace?: string + method?: number + code?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "code" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/callback", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Provider extends HeyApiClient { + /** + * List providers + * + * Get a list of all available AI providers, including both available and connected ones. + */ + public list( parameters?: { directory?: string workspace?: string - worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1619,22 +2747,51 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + return (options?.client ?? this.client).get({ + url: "/provider", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } + + /** + * Get provider auth methods + * + * Retrieve available authentication methods for all AI providers. + */ + public auth( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/provider/auth", + ...options, + ...params, + }) + } + + private _oauth?: Oauth + get oauth(): Oauth { + return (this._oauth ??= new Oauth({ client: this.client })) + } } export class Session2 extends HeyApiClient { @@ -1691,6 +2848,12 @@ export class Session2 extends HeyApiClient { workspace?: string parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string }, @@ -1705,6 +2868,8 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "body", key: "parentID" }, { in: "body", key: "title" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, { in: "body", key: "permission" }, { in: "body", key: "workspaceID" }, ], @@ -1926,184 +3091,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Initialize session - * - * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. - */ - public init( - parameters: { - sessionID: string - directory?: string - workspace?: string - modelID?: string - providerID?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Fork session - * - * Create a new session by forking an existing session at a specific message point. - */ - public fork( - parameters: { - sessionID: string - directory?: string - workspace?: string - messageID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Abort session - * - * Abort an active session and stop any ongoing AI processing or command execution. - */ - public abort( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", - ...options, - ...params, - }) - } - - /** - * Unshare session - * - * Remove the shareable link for a session, making it private again. - */ - public unshare( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - - /** - * Share session - * - * Create a shareable link for a session, allowing others to view the conversation. - */ - public share( - parameters: { - sessionID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", - ...options, - ...params, - }) - } - /** * Get message diff * @@ -2138,49 +3125,6 @@ export class Session2 extends HeyApiClient { }) } - /** - * Summarize session - * - * Generate a concise summary of the session using AI compaction to preserve key information. - */ - public summarize( - parameters: { - sessionID: string - directory?: string - workspace?: string - providerID?: string - modelID?: string - auto?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - /** * Get session messages * @@ -2280,7 +3224,7 @@ export class Session2 extends HeyApiClient { /** * Delete message * - * Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message. + * Permanently delete a specific message and all of its parts from a session without reverting file changes. */ public deleteMessage( parameters: { @@ -2349,6 +3293,227 @@ export class Session2 extends HeyApiClient { }) } + /** + * Fork session + * + * Create a new session by forking an existing session at a specific message point. + */ + public fork( + parameters: { + sessionID: string + directory?: string + workspace?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/fork", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Abort session + * + * Abort an active session and stop any ongoing AI processing or command execution. + */ + public abort( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/abort", + ...options, + ...params, + }) + } + + /** + * Initialize session + * + * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. + */ + public init( + parameters: { + sessionID: string + directory?: string + workspace?: string + modelID?: string + providerID?: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "modelID" }, + { in: "body", key: "providerID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/init", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Unshare session + * + * Remove the shareable link for a session, making it private again. + */ + public unshare( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Share session + * + * Create a shareable link for a session, allowing others to view the conversation. + */ + public share( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/share", + ...options, + ...params, + }) + } + + /** + * Summarize session + * + * Generate a concise summary of the session using AI compaction to preserve key information. + */ + public summarize( + parameters: { + sessionID: string + directory?: string + workspace?: string + providerID?: string + modelID?: string + auto?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "auto" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/summarize", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send async message * @@ -2591,7 +3756,7 @@ export class Session2 extends HeyApiClient { export class Part extends HeyApiClient { /** - * Delete a part from a message + * Delete a part from a message. */ public delete( parameters: { @@ -2625,7 +3790,7 @@ export class Part extends HeyApiClient { } /** - * Update a part in a message + * Update a part in a message. */ public update( parameters: { @@ -2666,386 +3831,6 @@ export class Part extends HeyApiClient { } } -export class Permission extends HeyApiClient { - /** - * Respond to permission - * - * Approve or deny a permission request from the AI assistant. - * - * @deprecated - */ - public respond( - parameters: { - sessionID: string - permissionID: string - directory?: string - workspace?: string - response?: "once" | "always" | "reject" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "response" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Respond to permission request - * - * Approve or deny a permission request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - reply?: "once" | "always" | "reject" - message?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List pending permissions - * - * Get all pending permission requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/permission", - ...options, - ...params, - }) - } -} - -export class Question extends HeyApiClient { - /** - * List pending questions - * - * Get all pending question requests across all sessions. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/question", - ...options, - ...params, - }) - } - - /** - * Reply to question request - * - * Provide answers to a question request from the AI assistant. - */ - public reply( - parameters: { - requestID: string - directory?: string - workspace?: string - answers?: Array - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Reject question request - * - * Reject a question request from the AI assistant. - */ - public reject( - parameters: { - requestID: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "requestID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", - ...options, - ...params, - }) - } -} - -export class Oauth extends HeyApiClient { - /** - * OAuth authorize - * - * Initiate OAuth authorization for a specific AI provider to get an authorization URL. - */ - public authorize( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - inputs?: { - [key: string]: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthAuthorizeResponses, - ProviderOauthAuthorizeErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/authorize", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * OAuth callback - * - * Handle the OAuth callback from a provider after user authorization. - */ - public callback( - parameters: { - providerID: string - directory?: string - workspace?: string - method?: number - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "providerID" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ProviderOauthCallbackResponses, - ProviderOauthCallbackErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/callback", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Provider extends HeyApiClient { - /** - * List providers - * - * Get a list of all available AI providers, including both available and connected ones. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider", - ...options, - ...params, - }) - } - - /** - * Get provider auth methods - * - * Retrieve available authentication methods for all AI providers. - */ - public auth( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/provider/auth", - ...options, - ...params, - }) - } - - private _oauth?: Oauth - get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) - } -} - export class History extends HeyApiClient { /** * List sync events @@ -3173,187 +3958,56 @@ export class Sync extends HeyApiClient { }) } + /** + * Steal session into workspace + * + * Update a session to belong to the current workspace through the sync event system. + */ + public steal( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/steal", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _history?: History get history(): History { return (this._history ??= new History({ client: this.client })) } } -export class Find extends HeyApiClient { +export class Session3 extends HeyApiClient { /** - * Find text + * List v2 sessions * - * Search for text patterns across files in the project using ripgrep. - */ - public text( - parameters: { - directory?: string - workspace?: string - pattern: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find", - ...options, - ...params, - }) - } - - /** - * Find files - * - * Search for files or directories by name or pattern in the project directory. - */ - public files( - parameters: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/file", - ...options, - ...params, - }) - } - - /** - * Find symbols - * - * Search for workspace symbols like functions, classes, and variables using LSP. - */ - public symbols( - parameters: { - directory?: string - workspace?: string - query: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/find/symbol", - ...options, - ...params, - }) - } -} - -export class File extends HeyApiClient { - /** - * List files - * - * List files and directories in a specified path. + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. */ public list( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file", - ...options, - ...params, - }) - } - - /** - * Read file - * - * Read the content of a specified file. - */ - public read( - parameters: { - directory?: string - workspace?: string - path: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "path" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/file/content", - ...options, - ...params, - }) - } - - /** - * Get file status - * - * Get the git status of all files in the project. - */ - public status( parameters?: { directory?: string workspace?: string @@ -3371,57 +4025,25 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/file/status", + return (options?.client ?? this.client).get({ + url: "/api/session", ...options, ...params, }) } -} -export class Event extends HeyApiClient { /** - * Subscribe to events + * Send v2 message * - * Get events + * Create a v2 session message and queue it for the agent loop. */ - public subscribe( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).sse.get({ - url: "/event", - ...options, - ...params, - }) - } -} - -export class Auth2 extends HeyApiClient { - /** - * Remove MCP OAuth - * - * Remove OAuth credentials for an MCP server - */ - public remove( + public prompt( parameters: { - name: string + sessionID: string directory?: string workspace?: string + prompt?: Prompt + delivery?: SessionDelivery }, options?: Options, ) { @@ -3430,81 +4052,17 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Start MCP OAuth - * - * Start OAuth authentication flow for a Model Context Protocol (MCP) server. - */ - public start( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", - ...options, - ...params, - }) - } - - /** - * Complete MCP OAuth - * - * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. - */ - public callback( - parameters: { - name: string - directory?: string - workspace?: string - code?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "code" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", ...options, ...params, headers: { @@ -3516,13 +4074,13 @@ export class Auth2 extends HeyApiClient { } /** - * Authenticate MCP OAuth + * Compact v2 session * - * Start OAuth flow and wait for callback (opens browser) + * Compact a v2 session conversation. */ - public authenticate( + public compact( parameters: { - name: string + sessionID: string directory?: string workspace?: string }, @@ -3533,156 +4091,121 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", + ...options, + ...params, + }) + } + + /** + * Wait for v2 session + * + * Wait for a v2 session agent loop to become idle. + */ + public wait( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", + ...options, + ...params, + }) + } + + /** + * Get v2 session context + * + * Retrieve the active context messages for a v2 session (all messages after the last compaction). + */ + public context( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", + ...options, + ...params, + }) + } + + /** + * Get v2 session messages + * + * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public messages( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) } } -export class Mcp extends HeyApiClient { - /** - * Get MCP status - * - * Get the status of all Model Context Protocol (MCP) servers. - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/mcp", - ...options, - ...params, - }) - } - - /** - * Add MCP server - * - * Dynamically add a new Model Context Protocol (MCP) server to the system. - */ - public add( - parameters?: { - directory?: string - workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Connect an MCP server - */ - public connect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", - ...options, - ...params, - }) - } - - /** - * Disconnect an MCP server - */ - public disconnect( - parameters: { - name: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "name" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", - ...options, - ...params, - }) - } - - private _auth?: Auth2 - get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) +export class V2 extends HeyApiClient { + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) } } @@ -3690,7 +4213,7 @@ export class Control extends HeyApiClient { /** * Get next TUI request * - * Retrieve the next TUI (Terminal User Interface) request from the queue for processing. + * Retrieve the next TUI request from the queue for processing. */ public next( parameters?: { @@ -3759,7 +4282,7 @@ export class Tui extends HeyApiClient { /** * Append TUI prompt * - * Append prompt to the TUI + * Append prompt to the TUI. */ public appendPrompt( parameters?: { @@ -3826,7 +4349,7 @@ export class Tui extends HeyApiClient { /** * Open sessions dialog * - * Open the session dialog + * Open the session dialog. */ public openSessions( parameters?: { @@ -3856,7 +4379,7 @@ export class Tui extends HeyApiClient { /** * Open themes dialog * - * Open the theme dialog + * Open the theme dialog. */ public openThemes( parameters?: { @@ -3886,7 +4409,7 @@ export class Tui extends HeyApiClient { /** * Open models dialog * - * Open the model dialog + * Open the model dialog. */ public openModels( parameters?: { @@ -3916,7 +4439,7 @@ export class Tui extends HeyApiClient { /** * Submit TUI prompt * - * Submit the prompt + * Submit the prompt. */ public submitPrompt( parameters?: { @@ -3946,7 +4469,7 @@ export class Tui extends HeyApiClient { /** * Clear TUI prompt * - * Clear the prompt + * Clear the prompt. */ public clearPrompt( parameters?: { @@ -3976,7 +4499,7 @@ export class Tui extends HeyApiClient { /** * Execute TUI command * - * Execute a TUI command (e.g. agent_cycle) + * Execute a TUI command. */ public executeCommand( parameters?: { @@ -4013,7 +4536,7 @@ export class Tui extends HeyApiClient { /** * Show TUI toast * - * Show a toast notification in the TUI + * Show a toast notification in the TUI. */ public showToast( parameters?: { @@ -4056,13 +4579,13 @@ export class Tui extends HeyApiClient { /** * Publish TUI event * - * Publish a TUI event + * Publish a TUI event. */ public publish( parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 }, options?: Options, ) { @@ -4133,230 +4656,6 @@ export class Tui extends HeyApiClient { } } -export class Instance extends HeyApiClient { - /** - * Dispose instance - * - * Clean up and dispose the current OpenCode instance, releasing all resources. - */ - public dispose( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/instance/dispose", - ...options, - ...params, - }) - } -} - -export class Path extends HeyApiClient { - /** - * Get paths - * - * Retrieve the current working directory and related path information for the OpenCode instance. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/path", - ...options, - ...params, - }) - } -} - -export class Vcs extends HeyApiClient { - /** - * Get VCS info - * - * Retrieve version control system (VCS) information for the current project, such as git branch. - */ - public get( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs", - ...options, - ...params, - }) - } - - /** - * Get VCS diff - * - * Retrieve the current git diff for the working tree or against the default branch. - */ - public diff( - parameters: { - directory?: string - workspace?: string - mode: "git" | "branch" - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", - ...options, - ...params, - }) - } -} - -export class Command extends HeyApiClient { - /** - * List commands - * - * Get a list of all available commands in the OpenCode system. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/command", - ...options, - ...params, - }) - } -} - -export class Lsp extends HeyApiClient { - /** - * Get LSP status - * - * Get LSP server status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/lsp", - ...options, - ...params, - }) - } -} - -export class Formatter extends HeyApiClient { - /** - * Get formatter status - * - * Get formatter status - */ - public status( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/formatter", - ...options, - ...params, - }) - } -} - export class OpencodeClient extends HeyApiClient { public static readonly __registry = new HeyApiRegistry() @@ -4365,11 +4664,6 @@ export class OpencodeClient extends HeyApiClient { OpencodeClient.__registry.set(this, args?.key) } - private _global?: Global - get global(): Global { - return (this._global ??= new Global({ client: this.client })) - } - private _auth?: Auth get auth(): Auth { return (this._auth ??= new Auth({ client: this.client })) @@ -4380,19 +4674,14 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) + private _global?: Global + get global(): Global { + return (this._global ??= new Global({ client: this.client })) } - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) - } - - private _pty?: Pty - get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) } private _config?: Config2 @@ -4400,6 +4689,11 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -4410,36 +4704,6 @@ export class OpencodeClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _session?: Session2 - get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) - } - - private _part?: Part - get part(): Part { - return (this._part ??= new Part({ client: this.client })) - } - - private _permission?: Permission - get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) - } - - private _question?: Question - get question(): Question { - return (this._question ??= new Question({ client: this.client })) - } - - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) - } - - private _sync?: Sync - get sync(): Sync { - return (this._sync ??= new Sync({ client: this.client })) - } - private _find?: Find get find(): Find { return (this._find ??= new Find({ client: this.client })) @@ -4450,21 +4714,6 @@ export class OpencodeClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } - - private _mcp?: Mcp - get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) - } - - private _tui?: Tui - get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) - } - private _instance?: Instance get instance(): Instance { return (this._instance ??= new Instance({ client: this.client })) @@ -4494,4 +4743,59 @@ export class OpencodeClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } + + private _mcp?: Mcp + get mcp(): Mcp { + return (this._mcp ??= new Mcp({ client: this.client })) + } + + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + + private _pty?: Pty + get pty(): Pty { + return (this._pty ??= new Pty({ client: this.client })) + } + + private _question?: Question + get question(): Question { + return (this._question ??= new Question({ client: this.client })) + } + + private _permission?: Permission + get permission(): Permission { + return (this._permission ??= new Permission({ client: this.client })) + } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } + + private _part?: Part + get part(): Part { + return (this._part ??= new Part({ client: this.client })) + } + + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } + + private _tui?: Tui + get tui(): Tui { + return (this._tui ??= new Tui({ client: this.client })) + } } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9bb1e50aac..7734ca53eb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,96 +4,104 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number - } - sandboxes: Array +export type Event = + | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed + +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string } -export type EventProjectUpdated = { - type: "project.updated" - properties: Project -} - -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string } } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string } -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - -export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string - } -} - -export type EventLspUpdated = { - type: "lsp.updated" - properties: { - [key: string]: unknown - } -} - -export type EventMessagePartDelta = { - type: "message.part.delta" - properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} +export type Auth = OAuth | ApiAuth | WellKnownAuth export type PermissionRequest = { id: string @@ -110,20 +118,6 @@ export type PermissionRequest = { } } -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SnapshotFileDiff = { file: string patch: string @@ -132,14 +126,6 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - export type ProviderAuthError = { name: "ProviderAuthError" data: { @@ -201,35 +187,6 @@ export type ApiError = { } } -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventInstallationUpdated = { - type: "installation.updated" - properties: { - version: string - } -} - -export type EventInstallationUpdateAvailable = { - type: "installation.update-available" - properties: { - version: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -254,13 +211,7 @@ export type QuestionInfo = { * Available choices */ options: Array - /** - * Allow selecting multiple choices - */ multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ custom?: boolean } @@ -279,11 +230,6 @@ export type QuestionRequest = { tool?: QuestionTool } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - export type QuestionAnswer = Array export type QuestionReplied = { @@ -292,21 +238,11 @@ export type QuestionReplied = { answers: Array } -export type EventQuestionReplied = { - type: "question.replied" - properties: QuestionReplied -} - export type QuestionRejected = { sessionID: string requestID: string } -export type EventQuestionRejected = { - type: "question.rejected" - properties: QuestionRejected -} - export type Todo = { /** * Brief description of the task @@ -322,14 +258,6 @@ export type Todo = { priority: string } -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type SessionStatus = | { type: "idle" @@ -344,29 +272,8 @@ export type SessionStatus = type: "busy" } -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - export type EventTuiPromptAppend = { + id: string type: "tui.prompt.append" properties: { text: string @@ -374,6 +281,7 @@ export type EventTuiPromptAppend = { } export type EventTuiCommandExecute = { + id: string type: "tui.command.execute" properties: { command: @@ -398,19 +306,18 @@ export type EventTuiCommandExecute = { } export type EventTuiToastShow = { + id: string type: "tui.toast.show" properties: { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } } export type EventTuiSessionSelect = { + id: string type: "tui.session.select" properties: { /** @@ -420,83 +327,28 @@ export type EventTuiSessionSelect = { } } -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string + time: { + created: number + updated: number + initialized?: number } + sandboxes: Array } export type Pty = { @@ -509,35 +361,6 @@ export type Pty = { pid: number } -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - export type OutputFormatText = { type: "text" } @@ -623,22 +446,6 @@ export type AssistantMessage = { export type Message = UserMessage | AssistantMessage -export type EventMessageUpdated = { - type: "message.updated" - properties: { - sessionID: string - info: Message - } -} - -export type EventMessageRemoved = { - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - export type TextPart = { id: string sessionID: string @@ -902,24 +709,6 @@ export type Part = | RetryPart | CompactionPart -export type EventMessagePartUpdated = { - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number - } -} - -export type EventMessagePartRemoved = { - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -948,6 +737,12 @@ export type Session = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -964,146 +759,10 @@ export type Session = { } } -export type EventSessionCreated = { - type: "session.created" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionUpdated = { - type: "session.updated" - properties: { - sessionID: string - info: Session - } -} - -export type EventSessionDeleted = { - type: "session.deleted" - properties: { - sessionID: string - info: Session - } -} - -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message - } -} - -export type SyncEventMessageRemoved = { - type: "sync" - name: "message.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "sync" - name: "message.part.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "sync" - name: "message.part.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "sync" - name: "session.created.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "sync" - name: "session.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: { - id?: string | null - slug?: string | null - projectID?: string | null - workspaceID?: string | null - directory?: string | null - path?: string | null - parentID?: string | null - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - share?: { - url?: string | null - } - title?: string | null - version?: string | null - time?: { - created?: number | null - updated?: number | null - compacting?: number | null - archived?: number | null - } - permission?: PermissionRuleset | null - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - } - } -} - -export type SyncEventSessionDeleted = { - type: "sync" - name: "session.deleted.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } +export type Prompt = { + text: string + files?: Array + agents?: Array } export type GlobalEvent = { @@ -1111,10 +770,7 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventProjectUpdated | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed | EventFileEdited | EventFileWatcherUpdated | EventLspClientDiagnostics @@ -1140,10 +796,10 @@ export type GlobalEvent = { | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -1158,6 +814,34 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextStepFailed + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolFailed + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -1165,6 +849,32 @@ export type GlobalEvent = { | SyncEventSessionCreated | SyncEventSessionUpdated | SyncEventSessionDeleted + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolFailed + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionDelta + | SyncEventSessionNextCompactionEnded } /** @@ -1176,25 +886,10 @@ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" * Server configuration for opencode serve and web commands */ export type ServerConfig = { - /** - * Port to listen on - */ port?: number - /** - * Hostname to listen on - */ hostname?: string - /** - * Enable mDNS service discovery - */ mdns?: boolean - /** - * Custom domain name for mDNS service (default: opencode.local) - */ mdnsDomain?: string - /** - * Additional domains to allow for CORS - */ cors?: Array } @@ -1229,28 +924,16 @@ export type PermissionConfig = export type AgentConfig = { model?: string - /** - * Default model variant for this agent (applies only when using the agent's configured model). - */ variant?: string temperature?: number top_p?: number prompt?: string - /** - * @deprecated Use 'permission' field instead - */ tools?: { [key: string]: boolean } disable?: boolean - /** - * Description of when to use the agent - */ description?: string mode?: "subagent" | "primary" | "all" - /** - * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) - */ hidden?: boolean options?: { [key: string]: unknown @@ -1259,13 +942,7 @@ export type AgentConfig = { * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - /** - * Maximum number of agentic iterations before forcing text-only response - */ steps?: number - /** - * @deprecated Use 'steps' field instead. - */ maxSteps?: number permission?: PermissionConfig [key: string]: @@ -1306,21 +983,12 @@ export type ProviderConfig = { options?: { apiKey?: string baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ setCacheKey?: boolean /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false - /** - * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. - */ chunkTimeout?: number [key: string]: unknown | string | boolean | number | false | number | undefined } @@ -1377,9 +1045,6 @@ export type ProviderConfig = { */ variants?: { [key: string]: { - /** - * Disable this variant for the model - */ disabled?: boolean [key: string]: unknown | boolean | undefined } @@ -1397,38 +1062,17 @@ export type McpLocalConfig = { * Command and arguments to run the MCP server */ command: Array - /** - * Environment variables to set when running the MCP server - */ environment?: { [key: string]: string } - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } export type McpOAuthConfig = { - /** - * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. - */ clientId?: string - /** - * OAuth client secret (if required by the authorization server) - */ clientSecret?: string - /** - * OAuth scopes to request during authorization - */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ redirectUri?: string } @@ -1441,13 +1085,7 @@ export type McpRemoteConfig = { * URL of the remote MCP server */ url: string - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Headers to send with the request - */ headers?: { [key: string]: string } @@ -1455,9 +1093,6 @@ export type McpRemoteConfig = { * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ oauth?: McpOAuthConfig | false - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } @@ -1467,19 +1102,10 @@ export type McpRemoteConfig = { export type LayoutConfig = "auto" | "stretch" export type Config = { - /** - * JSON schema reference for configuration validation - */ $schema?: string - /** - * Default shell to use for terminal and bash tool - */ shell?: string logLevel?: LogLevel server?: ServerConfig - /** - * Command configuration, see https://opencode.ai/docs/commands - */ command?: { [key: string]: { template: string @@ -1489,25 +1115,13 @@ export type Config = { subtask?: boolean } } - /** - * Additional skill folder paths - */ skills?: { - /** - * Additional paths to skill folders - */ paths?: Array - /** - * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) - */ urls?: Array } watcher?: { ignore?: Array } - /** - * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. - */ snapshot?: boolean plugin?: Array< | string @@ -1518,53 +1132,23 @@ export type Config = { }, ] > - /** - * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing - */ share?: "manual" | "auto" | "disabled" - /** - * @deprecated Use 'share' field instead. Share newly created sessions automatically - */ autoshare?: boolean /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ autoupdate?: boolean | "notify" - /** - * Disable providers that are loaded automatically - */ disabled_providers?: Array - /** - * When set, ONLY these providers will be enabled. All other providers will be ignored - */ enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ small_model?: string - /** - * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. - */ default_agent?: string - /** - * Custom username to display in conversations instead of system username - */ username?: string - /** - * @deprecated Use `agent` field instead. - */ mode?: { build?: AgentConfig plan?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Agent configuration, see https://opencode.ai/docs/agents - */ agent?: { plan?: AgentConfig build?: AgentConfig @@ -1575,15 +1159,9 @@ export type Config = { compaction?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Custom provider configurations and model overrides - */ provider?: { [key: string]: ProviderConfig } - /** - * MCP (Model Context Protocol) server configurations - */ mcp?: { [key: string]: | McpLocalConfig @@ -1592,6 +1170,9 @@ export type Config = { enabled: boolean } } + /** + * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ formatter?: | boolean | { @@ -1604,6 +1185,9 @@ export type Config = { extensions?: Array } } + /** + * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ lsp?: | boolean | { @@ -1623,9 +1207,6 @@ export type Config = { } } } - /** - * Additional instruction files or patterns to include - */ instructions?: Array layout?: LayoutConfig permission?: PermissionConfig @@ -1633,121 +1214,29 @@ export type Config = { [key: string]: boolean } enterprise?: { - /** - * Enterprise URL - */ url?: string } - /** - * Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned. - */ tool_output?: { - /** - * Maximum lines of tool output before it is truncated and saved to disk (default: 2000) - */ max_lines?: number - /** - * Maximum bytes of tool output before it is truncated and saved to disk (default: 51200) - */ max_bytes?: number } compaction?: { - /** - * Enable automatic compaction when context is full (default: true) - */ auto?: boolean - /** - * Enable pruning of old tool outputs (default: true) - */ prune?: boolean - /** - * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) - */ tail_turns?: number - /** - * Maximum number of tokens from recent turns to preserve verbatim after compaction - */ preserve_recent_tokens?: number - /** - * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. - */ reserved?: number } experimental?: { disable_paste_summary?: boolean - /** - * Enable the batch tool - */ batch_tool?: boolean - /** - * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) - */ openTelemetry?: boolean - /** - * Tools that should only be available to primary agents. - */ primary_tools?: Array - /** - * Continue the agent loop when a tool call is denied - */ continue_loop_on_deny?: boolean - /** - * Timeout in milliseconds for model context protocol (MCP) requests - */ mcp_timeout?: number } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - -export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} - -export type ApiAuth = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} - -export type Auth = OAuth | ApiAuth | WellKnownAuth - -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type Model = { id: string providerID: string @@ -1839,8 +1328,6 @@ export type ConsoleState = { switchableOrgCount: number } -export type ToolIds = Array - export type ToolListItem = { id: string description: string @@ -1849,11 +1336,7 @@ export type ToolListItem = { export type ToolList = Array -export type Worktree = { - name: string - branch: string - directory: string -} +export type ToolIds = Array export type WorktreeCreateInput = { name?: string @@ -1863,6 +1346,12 @@ export type WorktreeCreateInput = { startCommand?: string } +export type Worktree = { + name: string + branch: string + directory: string +} + export type WorktreeRemoveInput = { directory: string } @@ -1895,6 +1384,12 @@ export type GlobalSession = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -1920,93 +1415,6 @@ export type McpResource = { client: string } -export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type AgentPartInput = { - id?: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { - providerID: string - modelID: string - } - command?: string -} - -export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - > -} - -export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} - export type Symbol = { name: string kind: number @@ -2053,88 +1461,6 @@ export type File = { status: "added" | "deleted" | "modified" } -export type Event = - | EventProjectUpdated - | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - -export type McpStatusConnected = { - status: "connected" -} - -export type McpStatusDisabled = { - status: "disabled" -} - -export type McpStatusFailed = { - status: "failed" - error: string -} - -export type McpStatusNeedsAuth = { - status: "needs_auth" -} - -export type McpStatusNeedsClientRegistration = { - status: "needs_client_registration" - error: string -} - -export type McpStatus = - | McpStatusConnected - | McpStatusDisabled - | McpStatusFailed - | McpStatusNeedsAuth - | McpStatusNeedsClientRegistration - -export type McpUnsupportedOAuthError = { - error: string -} - export type Path = { home: string state: string @@ -2202,6 +1528,1807 @@ export type FormatterStatus = { enabled: boolean } +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration + +export type McpUnsupportedOAuthError = { + error: string +} + +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type EventTuiPromptAppend2 = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute2 = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow2 = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect2 = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type SyncEventMessageUpdated = { + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Message + } +} + +export type SyncEventMessageRemoved = { + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + } +} + +export type SyncEventMessagePartUpdated = { + type: "sync" + name: "message.part.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + part: Part + time: number + } +} + +export type SyncEventMessagePartRemoved = { + type: "sync" + name: "message.part.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SyncEventSessionCreated = { + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionUpdated = { + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: { + id?: string | null + slug?: string | null + projectID?: string | null + workspaceID?: string | null + directory?: string | null + path?: string | null + parentID?: string | null + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } | null + share?: { + url?: string | null + } + title?: string | null + agent?: string | null + model?: { + id: string + providerID: string + variant?: string + } | null + version?: string | null + time?: { + created?: number | null + updated?: number | null + compacting?: number | null + archived?: number | null + } + permission?: PermissionRuleset | null + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } | null + } + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionNextAgentSwitched = { + type: "sync" + name: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + } +} + +export type SyncEventSessionNextModelSwitched = { + type: "sync" + name: "session.next.model.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant: string + } + } +} + +export type SyncEventSessionNextPrompted = { + type: "sync" + name: "session.next.prompted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + name: "session.next.synthetic.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextShellStarted = { + type: "sync" + name: "session.next.shell.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type SyncEventSessionNextShellEnded = { + type: "sync" + name: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + name: "session.next.step.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant: string + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + name: "session.next.step.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepFailed = { + type: "sync" + name: "session.next.step.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + error: SessionErrorUnknown + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + } +} + +export type SyncEventSessionNextTextDelta = { + type: "sync" + name: "session.next.text.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + delta: string + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + name: "session.next.text.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + name: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolFailed = { + type: "sync" + name: "session.next.tool.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + name: "session.next.retried.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SyncEventSessionNextCompactionStarted = { + type: "sync" + name: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type SyncEventSessionNextCompactionDelta = { + type: "sync" + name: "session.next.compaction.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextCompactionEnded = { + type: "sync" + name: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventLspClientDiagnostics = { + id: string + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + +export type EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventMessagePartDelta = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventPermissionAsked = { + id: string + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: QuestionRequest +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: QuestionReplied +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: QuestionRejected +} + +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventSessionStatus = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: Project +} + +export type EventVcsBranchUpdated = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceStatus = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady = { + id: string + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + model: { + id: string + providerID: string + variant: string + } + } +} + +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant: string + } + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: SessionErrorUnknown + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolFailed = { + id: string + type: "session.next.tool.failed" + properties: { + timestamp: number + sessionID: string + callID: string + error: SessionErrorUnknown + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type SessionInfo = { + id: string + parentID?: string + projectID: string + workspaceID?: string + path?: string + agent?: string + model?: { + id: string + providerID: string + variant: string + } + time: { + created: number + updated: number + archived?: number + } + title: string +} + +export type SessionDelivery = "immediate" | "deferred" + +export type SessionMessageAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: { + id: string + providerID: string + variant: string + } +} + +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + structured: { + [key: string]: unknown + } +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: SessionErrorUnknown +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: { + id: string + providerID: string + variant: string + } + content: Array + snapshot?: { + start?: string + end?: string + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: SessionErrorUnknown +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + include?: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type EventTuiToastShow1 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + +export type AuthSetData = { + body?: Auth + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean +} + +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + extra?: { + [key: string]: unknown + } + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/log" +} + +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean +} + +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + export type GlobalHealthData = { body?: never path?: never @@ -2329,276 +3456,924 @@ export type GlobalUpgradeResponses = { export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] -export type AuthRemoveData = { - body?: never - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] - -export type AuthRemoveResponses = { - /** - * Successfully removed authentication credentials - */ - 200: boolean -} - -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] - -export type AuthSetData = { - body?: Auth - path: { - providerID: string - } - query?: never - url: "/auth/{providerID}" -} - -export type AuthSetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] - -export type AuthSetResponses = { - /** - * Successfully set authentication credentials - */ - 200: boolean -} - -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] - -export type AppLogData = { - body?: { - /** - * Service name for the log entry - */ - service: string - /** - * Log level - */ - level: "debug" | "info" | "error" | "warn" - /** - * Log message - */ - message: string - /** - * Additional metadata for the log entry - */ - extra?: { - [key: string]: unknown - } - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/log" -} - -export type AppLogErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppLogError = AppLogErrors[keyof AppLogErrors] - -export type AppLogResponses = { - /** - * Log entry written successfully - */ - 200: boolean -} - -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] - -export type ExperimentalWorkspaceAdaptorListData = { +export type EventSubscribeData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adaptor" + url: "/event" } -export type ExperimentalWorkspaceAdaptorListResponses = { +export type EventSubscribeResponses = { /** - * Workspace adaptors + * Event stream */ - 200: Array<{ - type: string - name: string - description: string - }> + 200: Event } -export type ExperimentalWorkspaceAdaptorListResponse = - ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] -export type ExperimentalWorkspaceListData = { +export type ConfigGetData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceListResponses = { +export type ConfigGetResponses = { /** - * Workspaces + * Get config info */ - 200: Array + 200: Config } -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } +export type ConfigUpdateData = { + body?: Config path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/config" } -export type ExperimentalWorkspaceCreateErrors = { +export type ConfigUpdateErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] -export type ExperimentalWorkspaceCreateResponses = { +export type ConfigUpdateResponses = { /** - * Workspace created + * Successfully updated config */ - 200: Workspace + 200: Config } -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] -export type ExperimentalWorkspaceStatusData = { +export type ConfigProvidersData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/status" + url: "/config/providers" } -export type ExperimentalWorkspaceStatusResponses = { +export type ConfigProvidersResponses = { /** - * Workspace status - */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> -} - -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] - -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}" -} - -export type ExperimentalWorkspaceRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] - -export type ExperimentalWorkspaceRemoveResponses = { - /** - * Workspace removed - */ - 200: Workspace -} - -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] - -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/{id}/session-restore" -} - -export type ExperimentalWorkspaceSessionRestoreErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] - -export type ExperimentalWorkspaceSessionRestoreResponses = { - /** - * Session replay started + * List of providers */ 200: { - total: number + providers: Array + default: { + [key: string]: string + } } } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ExperimentalConsoleGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console" +} + +export type ExperimentalConsoleGetResponses = { + /** + * Active Console provider metadata + */ + 200: ConsoleState +} + +export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] + +export type ExperimentalConsoleListOrgsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/orgs" +} + +export type ExperimentalConsoleListOrgsResponses = { + /** + * Switchable Console orgs + */ + 200: { + orgs: Array<{ + accountID: string + accountEmail: string + accountUrl: string + orgID: string + orgName: string + active: boolean + }> + } +} + +export type ExperimentalConsoleListOrgsResponse = + ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] + +export type ExperimentalConsoleSwitchOrgData = { + body?: { + accountID: string + orgID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/switch" +} + +export type ExperimentalConsoleSwitchOrgResponses = { + /** + * Switch success + */ + 200: boolean +} + +export type ExperimentalConsoleSwitchOrgResponse = + ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] + +export type ToolListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + provider: string + model: string + } + url: "/experimental/tool" +} + +export type ToolListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolListError = ToolListErrors[keyof ToolListErrors] + +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type ToolIdsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/tool/ids" +} + +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] + +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type WorktreeRemoveData = { + body?: WorktreeRemoveInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] + +export type WorktreeRemoveResponses = { + /** + * Worktree removed + */ + 200: boolean +} + +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] + +export type WorktreeListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeListResponses = { + /** + * List of worktree directories + */ + 200: Array +} + +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] + +export type WorktreeCreateData = { + body?: WorktreeCreateInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree" +} + +export type WorktreeCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] + +export type WorktreeCreateResponses = { + /** + * Worktree created + */ + 200: Worktree +} + +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] + +export type WorktreeResetData = { + body?: WorktreeResetInput + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/worktree/reset" +} + +export type WorktreeResetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] + +export type WorktreeResetResponses = { + /** + * Worktree reset + */ + 200: boolean +} + +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] + +export type ExperimentalSessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" + } + url: "/experimental/session" +} + +export type ExperimentalSessionListResponses = { + /** + * List of sessions + */ + 200: Array +} + +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] + +export type ExperimentalResourceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/resource" +} + +export type ExperimentalResourceListResponses = { + /** + * MCP resources + */ + 200: { + [key: string]: McpResource + } +} + +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] + +export type FindTextData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + pattern: string + } + url: "/find" +} + +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> +} + +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] + +export type FindFilesData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number + } + url: "/find/file" +} + +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array +} + +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] + +export type FindSymbolsData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + query: string + } + url: "/find/symbol" +} + +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array +} + +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] + +export type FileListData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file" +} + +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array +} + +export type FileListResponse = FileListResponses[keyof FileListResponses] + +export type FileReadData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + path: string + } + url: "/file/content" +} + +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent +} + +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] + +export type FileStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/file/status" +} + +export type FileStatusResponses = { + /** + * File status + */ + 200: Array +} + +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] + +export type InstanceDisposeData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/instance/dispose" +} + +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean +} + +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] + +export type PathGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/path" +} + +export type PathGetResponses = { + /** + * Path + */ + 200: Path +} + +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] + +export type VcsGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs" +} + +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo +} + +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] + +export type VcsDiffData = { + body?: never + path?: never + query: { + directory?: string + workspace?: string + mode: "git" | "branch" + } + url: "/vcs/diff" +} + +export type VcsDiffResponses = { + /** + * VCS diff + */ + 200: Array +} + +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] + +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/command" +} + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type AppAgentsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/agent" +} + +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array +} + +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] + +export type AppSkillsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/skill" +} + +export type AppSkillsResponses = { + /** + * List of skills + */ + 200: Array<{ + name: string + description: string + location: string + content: string + }> +} + +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] + +export type LspStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp" +} + +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array +} + +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] + +export type FormatterStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/formatter" +} + +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array +} + +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] + +export type McpStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] + +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/mcp" +} + +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpAddError = McpAddErrors[keyof McpAddErrors] + +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus + } +} + +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] + +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + authorizationUrl: string + oauthState: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + code: string + } + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * McpUnsupportedOAuthError + */ + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + +export type McpConnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/connect" +} + +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean +} + +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] + +export type McpDisconnectData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/mcp/{name}/disconnect" +} + +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean +} + +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] export type ProjectListData = { body?: never @@ -2878,7 +4653,7 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] -export type PtyConnectData = { +export type PtyConnectTokenData = { body?: never path: { ptyID: string @@ -2887,430 +4662,313 @@ export type PtyConnectData = { directory?: string workspace?: string } - url: "/pty/{ptyID}/connect" + url: "/pty/{ptyID}/connect-token" } -export type PtyConnectErrors = { +export type PtyConnectTokenErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden /** * Not found */ 404: NotFoundError } -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] +export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] -export type PtyConnectResponses = { +export type PtyConnectTokenResponses = { /** - * Connected session + * WebSocket connect token */ - 200: boolean + 200: { + ticket: string + expires_in: number + } } -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] -export type ConfigGetData = { +export type QuestionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config" + url: "/question" } -export type ConfigGetResponses = { +export type QuestionListResponses = { /** - * Get config info + * List of pending questions */ - 200: Config + 200: Array } -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] -export type ConfigUpdateData = { - body?: Config - path?: never +export type QuestionReplyData = { + body?: { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array + } + path: { + requestID: string + } query?: { directory?: string workspace?: string } - url: "/config" + url: "/question/{requestID}/reply" } -export type ConfigUpdateErrors = { +export type QuestionReplyErrors = { /** * Bad request */ 400: BadRequestError -} - -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] - -export type ConfigUpdateResponses = { /** - * Successfully updated config + * Not found */ - 200: Config + 404: NotFoundError } -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] -export type ConfigProvidersData = { +export type QuestionReplyResponses = { + /** + * Question answered successfully + */ + 200: boolean +} + +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] + +export type QuestionRejectData = { + body?: never + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/question/{requestID}/reject" +} + +export type QuestionRejectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] + +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + +export type PermissionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config/providers" + url: "/permission" } -export type ConfigProvidersResponses = { +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + message?: string + } + path: { + requestID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/permission/{requestID}/reply" +} + +export type PermissionReplyErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] + +export type PermissionReplyResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] + +export type ProviderListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/provider" +} + +export type ProviderListResponses = { /** * List of providers */ 200: { - providers: Array + all: Array default: { [key: string]: string } + connected: Array } } -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] -export type ExperimentalConsoleGetData = { +export type ProviderAuthData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console" + url: "/provider/auth" } -export type ExperimentalConsoleGetResponses = { +export type ProviderAuthResponses = { /** - * Active Console provider metadata - */ - 200: ConsoleState -} - -export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] - -export type ExperimentalConsoleListOrgsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/console/orgs" -} - -export type ExperimentalConsoleListOrgsResponses = { - /** - * Switchable Console orgs + * Provider auth methods */ 200: { - orgs: Array<{ - accountID: string - accountEmail: string - accountUrl: string - orgID: string - orgName: string - active: boolean - }> + [key: string]: Array } } -export type ExperimentalConsoleListOrgsResponse = - ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] -export type ExperimentalConsoleSwitchOrgData = { +export type ProviderOauthAuthorizeData = { body?: { - accountID: string - orgID: string + /** + * Auth method index + */ + method: number + inputs?: { + [key: string]: string + } + } + path: { + providerID: string } - path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console/switch" + url: "/provider/{providerID}/oauth/authorize" } -export type ExperimentalConsoleSwitchOrgResponses = { +export type ProviderOauthAuthorizeErrors = { /** - * Switch success + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] + +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization +} + +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] + +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number + code?: string + } + path: { + providerID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/provider/{providerID}/oauth/callback" +} + +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully */ 200: boolean } -export type ExperimentalConsoleSwitchOrgResponse = - ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] - -export type ToolIdsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/tool/ids" -} - -export type ToolIdsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] - -export type ToolIdsResponses = { - /** - * Tool IDs - */ - 200: ToolIds -} - -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] - -export type ToolListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - provider: string - model: string - } - url: "/experimental/tool" -} - -export type ToolListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ToolListError = ToolListErrors[keyof ToolListErrors] - -export type ToolListResponses = { - /** - * Tools - */ - 200: ToolList -} - -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] - -export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] - -export type WorktreeRemoveResponses = { - /** - * Worktree removed - */ - 200: boolean -} - -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] - -export type WorktreeListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeListResponses = { - /** - * List of worktree directories - */ - 200: Array -} - -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] - -export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree" -} - -export type WorktreeCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] - -export type WorktreeCreateResponses = { - /** - * Worktree created - */ - 200: Worktree -} - -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] - -export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/worktree/reset" -} - -export type WorktreeResetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] - -export type WorktreeResetResponses = { - /** - * Worktree reset - */ - 200: boolean -} - -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] - -export type ExperimentalSessionListData = { - body?: never - path?: never - query?: { - /** - * Filter sessions by project directory - */ - directory?: string - workspace?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ - start?: number - /** - * Return sessions updated before this timestamp (milliseconds since epoch) - */ - cursor?: number - /** - * Filter sessions by title (case-insensitive) - */ - search?: string - /** - * Maximum number of sessions to return - */ - limit?: number - /** - * Include archived sessions (default false) - */ - archived?: boolean | "true" | "false" - } - url: "/experimental/session" -} - -export type ExperimentalSessionListResponses = { - /** - * List of sessions - */ - 200: Array -} - -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] - -export type ExperimentalResourceListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/resource" -} - -export type ExperimentalResourceListResponses = { - /** - * MCP resources - */ - 200: { - [key: string]: McpResource - } -} - -export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] export type SessionListData = { body?: never path?: never query?: { - /** - * Filter sessions by directory - */ directory?: string workspace?: string - /** - * List all sessions for the current project - */ scope?: "project" - /** - * Filter sessions by project-relative path - */ path?: string - /** - * Only return root sessions (no parentID) - */ roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ start?: number - /** - * Filter sessions by title (case-insensitive) - */ search?: string - /** - * Maximum number of sessions to return - */ limit?: number } url: "/session" @@ -3329,6 +4987,12 @@ export type SessionCreateData = { body?: { parentID?: string title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } permission?: PermissionRuleset workspaceID?: string } @@ -3564,169 +5228,6 @@ export type SessionTodoResponses = { export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] -export type SessionInitData = { - body?: { - modelID: string - providerID: string - messageID: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/init" -} - -export type SessionInitErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] - -export type SessionInitResponses = { - /** - * 200 - */ - 200: boolean -} - -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] - -export type SessionForkData = { - body?: { - messageID?: string - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/fork" -} - -export type SessionForkResponses = { - /** - * 200 - */ - 200: Session -} - -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] - -export type SessionAbortData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/abort" -} - -export type SessionAbortErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] - -export type SessionAbortResponses = { - /** - * Aborted session - */ - 200: boolean -} - -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] - -export type SessionUnshareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionUnshareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] - -export type SessionUnshareResponses = { - /** - * Successfully unshared session - */ - 200: Session -} - -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] - -export type SessionShareData = { - body?: never - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/share" -} - -export type SessionShareErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] - -export type SessionShareResponses = { - /** - * Successfully shared session - */ - 200: Session -} - -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] - export type SessionDiffData = { body?: never path: { @@ -3749,44 +5250,6 @@ export type SessionDiffResponses = { export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] -export type SessionSummarizeData = { - body?: { - providerID: string - modelID: string - auto?: boolean - } - path: { - sessionID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/session/{sessionID}/summarize" -} - -export type SessionSummarizeErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] - -export type SessionSummarizeResponses = { - /** - * Summarized session - */ - 200: boolean -} - -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] - export type SessionMessagesData = { body?: never path: { @@ -3795,9 +5258,6 @@ export type SessionMessagesData = { query?: { directory?: string workspace?: string - /** - * Maximum number of messages to return - */ limit?: number before?: string } @@ -3838,9 +5298,6 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -3957,21 +5414,42 @@ export type SessionMessageResponses = { export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] -export type PartDeleteData = { - body?: never +export type SessionForkData = { + body?: { + messageID?: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/fork" } -export type PartDeleteErrors = { +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session +} + +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] + +export type SessionAbortData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/abort" +} + +export type SessionAbortErrors = { /** * Bad request */ @@ -3982,32 +5460,34 @@ export type PartDeleteErrors = { 404: NotFoundError } -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] -export type PartDeleteResponses = { +export type SessionAbortResponses = { /** - * Successfully deleted part + * Aborted session */ 200: boolean } -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] -export type PartUpdateData = { - body?: Part +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}/init" } -export type PartUpdateErrors = { +export type SessionInitErrors = { /** * Bad request */ @@ -4018,16 +5498,122 @@ export type PartUpdateErrors = { 404: NotFoundError } -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] -export type PartUpdateResponses = { +export type SessionInitResponses = { /** - * Successfully updated part + * 200 */ - 200: Part + 200: boolean } -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] + +export type SessionUnshareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] + +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/share" +} + +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] + +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session +} + +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] + +export type SessionSummarizeData = { + body?: { + providerID: string + modelID: string + auto?: boolean + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/summarize" +} + +export type SessionSummarizeErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] + +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean +} + +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] export type SessionPromptAsyncData = { body?: { @@ -4038,9 +5624,6 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ tools?: { [key: string]: boolean } @@ -4286,22 +5869,21 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] -export type PermissionReplyData = { - body?: { - reply: "once" | "always" | "reject" - message?: string - } +export type PartDeleteData = { + body?: never path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/permission/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type PermissionReplyErrors = { +export type PartDeleteErrors = { /** * Bad request */ @@ -4312,73 +5894,32 @@ export type PermissionReplyErrors = { 404: NotFoundError } -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] -export type PermissionReplyResponses = { +export type PartDeleteResponses = { /** - * Permission processed successfully + * Successfully deleted part */ 200: boolean } -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] -export type PermissionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/permission" -} - -export type PermissionListResponses = { - /** - * List of pending permissions - */ - 200: Array -} - -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] - -export type QuestionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/question" -} - -export type QuestionListResponses = { - /** - * List of pending questions - */ - 200: Array -} - -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] - -export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } +export type PartUpdateData = { + body?: Part path: { - requestID: string + sessionID: string + messageID: string + partID: string } query?: { directory?: string workspace?: string } - url: "/question/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type QuestionReplyErrors = { +export type PartUpdateErrors = { /** * Bad request */ @@ -4389,182 +5930,16 @@ export type QuestionReplyErrors = { 404: NotFoundError } -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] -export type QuestionReplyResponses = { +export type PartUpdateResponses = { /** - * Question answered successfully + * Successfully updated part */ - 200: boolean + 200: Part } -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] - -export type QuestionRejectData = { - body?: never - path: { - requestID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/question/{requestID}/reject" -} - -export type QuestionRejectErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError -} - -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] - -export type QuestionRejectResponses = { - /** - * Question rejected successfully - */ - 200: boolean -} - -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] - -export type ProviderListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider" -} - -export type ProviderListResponses = { - /** - * List of providers - */ - 200: { - all: Array - default: { - [key: string]: string - } - connected: Array - } -} - -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] - -export type ProviderAuthData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} - -export type ProviderAuthResponses = { - /** - * Provider auth methods - */ - 200: { - [key: string]: Array - } -} - -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] - -export type ProviderOauthAuthorizeData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * Prompt inputs - */ - inputs?: { - [key: string]: string - } - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/authorize" -} - -export type ProviderOauthAuthorizeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] - -export type ProviderOauthAuthorizeResponses = { - /** - * Authorization URL and method - */ - 200: ProviderAuthAuthorization -} - -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] - -export type ProviderOauthCallbackData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * OAuth authorization code - */ - code?: string - } - path: { - /** - * Provider ID - */ - providerID: string - } - query?: { - directory?: string - workspace?: string - } - url: "/provider/{providerID}/oauth/callback" -} - -export type ProviderOauthCallbackErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] - -export type ProviderOauthCallbackResponses = { - /** - * OAuth callback processed successfully - */ - 200: boolean -} - -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] export type SyncStartData = { body?: never @@ -4626,6 +6001,38 @@ export type SyncReplayResponses = { export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] +export type SyncStealData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/steal" +} + +export type SyncStealErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] + +export type SyncStealResponses = { + /** + * Session stolen into workspace + */ + 200: { + sessionID: string + } +} + +export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] + export type SyncHistoryListData = { body?: { [key: string]: number @@ -4664,402 +6071,150 @@ export type SyncHistoryListResponses = { export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] -export type FindTextData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - pattern: string - } - url: "/find" -} - -export type FindTextResponses = { - /** - * Matches - */ - 200: Array<{ - path: { - text: string - } - lines: { - text: string - } - line_number: number - absolute_offset: number - submatches: Array<{ - match: { - text: string - } - start: number - end: number - }> - }> -} - -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] - -export type FindFilesData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} - -export type FindFilesResponses = { - /** - * File paths - */ - 200: Array -} - -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] - -export type FindSymbolsData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - } - url: "/find/symbol" -} - -export type FindSymbolsResponses = { - /** - * Symbols - */ - 200: Array -} - -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] - -export type FileListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} - -export type FileListResponses = { - /** - * Files and directories - */ - 200: Array -} - -export type FileListResponse = FileListResponses[keyof FileListResponses] - -export type FileReadData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file/content" -} - -export type FileReadResponses = { - /** - * File content - */ - 200: FileContent -} - -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] - -export type FileStatusData = { +export type V2SessionListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/file/status" + url: "/api/session" } -export type FileStatusResponses = { - /** - * File status - */ - 200: Array -} - -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] - -export type EventSubscribeData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/event" -} - -export type EventSubscribeResponses = { - /** - * Event stream - */ - 200: Event -} - -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] - -export type McpStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpStatusResponses = { - /** - * MCP server status - */ - 200: { - [key: string]: McpStatus - } -} - -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] - -export type McpAddData = { - body?: { - name: string - config: McpLocalConfig | McpRemoteConfig - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/mcp" -} - -export type McpAddErrors = { +export type V2SessionListErrors = { /** * Bad request */ 400: BadRequestError } -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] -export type McpAddResponses = { +export type V2SessionListResponses = { /** - * MCP server added successfully + * V2SessionsResponse */ - 200: { - [key: string]: McpStatus - } + 200: V2SessionsResponse } -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] -export type McpAuthRemoveData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthRemoveErrors = { - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] - -export type McpAuthRemoveResponses = { - /** - * OAuth credentials removed - */ - 200: { - success: true - } -} - -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] - -export type McpAuthStartData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth" -} - -export type McpAuthStartErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] - -export type McpAuthStartResponses = { - /** - * OAuth flow started - */ - 200: { - /** - * URL to open in browser for authorization - */ - authorizationUrl: string - } -} - -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] - -export type McpAuthCallbackData = { +export type V2SessionPromptData = { body?: { - /** - * Authorization code from OAuth callback - */ - code: string + prompt: Prompt + delivery?: SessionDelivery } path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth/callback" + url: "/api/session/{sessionID}/prompt" } -export type McpAuthCallbackErrors = { +export type V2SessionPromptResponses = { + /** + * Session.Message + */ + 200: SessionMessage +} + +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] + +export type V2SessionCompactData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/compact" +} + +export type V2SessionCompactResponses = { + /** + * + */ + 204: void +} + +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] + +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitResponses = { + /** + * + */ + 204: void +} + +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] + +export type V2SessionContextData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/context" +} + +export type V2SessionContextResponses = { + /** + * Success + */ + 200: Array +} + +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] + +export type V2SessionMessagesData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/message" +} + +export type V2SessionMessagesErrors = { /** * Bad request */ 400: BadRequestError +} + +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { /** - * Not found + * V2SessionMessagesResponse */ - 404: NotFoundError + 200: V2SessionMessagesResponse } -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] - -export type McpAuthCallbackResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] - -export type McpAuthAuthenticateData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/auth/authenticate" -} - -export type McpAuthAuthenticateErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found - */ - 404: NotFoundError -} - -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] - -export type McpAuthAuthenticateResponses = { - /** - * OAuth authentication completed - */ - 200: McpStatus -} - -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] - -export type McpConnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/connect" -} - -export type McpConnectResponses = { - /** - * MCP server connected successfully - */ - 200: boolean -} - -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] - -export type McpDisconnectData = { - body?: never - path: { - name: string - } - query?: { - directory?: string - workspace?: string - } - url: "/mcp/{name}/disconnect" -} - -export type McpDisconnectResponses = { - /** - * MCP server disconnected successfully - */ - 200: boolean -} - -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] export type TuiAppendPromptData = { body?: { @@ -5240,9 +6395,6 @@ export type TuiShowToastData = { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } path?: never @@ -5263,7 +6415,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 path?: never query?: { directory?: string @@ -5368,179 +6520,202 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] -export type InstanceDisposeData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/instance/dispose" + url: "/experimental/workspace/adapter" } -export type InstanceDisposeResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Instance disposed + * Workspace adapters + */ + 200: Array<{ + type: string + name: string + description: string + }> +} + +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra?: unknown | null + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceStatusData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/status" +} + +export type ExperimentalWorkspaceStatusResponses = { + /** + * Workspace status + */ + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + }> +} + +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] + +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceWarpData = { + body?: { + id: string + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/warp" +} + +export type ExperimentalWorkspaceWarpErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] + +export type ExperimentalWorkspaceWarpResponses = { + /** + * Session warped + */ + 204: void +} + +export type ExperimentalWorkspaceWarpResponse = + ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] + +export type PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect" +} + +export type PtyConnectErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session */ 200: boolean } -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] - -export type PathGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/path" -} - -export type PathGetResponses = { - /** - * Path - */ - 200: Path -} - -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] - -export type VcsGetData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/vcs" -} - -export type VcsGetResponses = { - /** - * VCS info - */ - 200: VcsInfo -} - -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] - -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} - -export type VcsDiffResponses = { - /** - * VCS diff - */ - 200: Array -} - -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] - -export type CommandListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/command" -} - -export type CommandListResponses = { - /** - * List of commands - */ - 200: Array -} - -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] - -export type AppAgentsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/agent" -} - -export type AppAgentsResponses = { - /** - * List of agents - */ - 200: Array -} - -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] - -export type AppSkillsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/skill" -} - -export type AppSkillsResponses = { - /** - * List of skills - */ - 200: Array<{ - name: string - description: string - location: string - content: string - }> -} - -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] - -export type LspStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/lsp" -} - -export type LspStatusResponses = { - /** - * LSP server status - */ - 200: Array -} - -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] - -export type FormatterStatusData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/formatter" -} - -export type FormatterStatusResponses = { - /** - * Formatter status - */ - 200: Array -} - -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 22e66c7d16..fea9dd5a95 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,16 +1,201 @@ { - "openapi": "3.1.1", + "openapi": "3.1.0", "info": { "title": "opencode", - "description": "opencode api", - "version": "1.0.0" + "version": "1.0.0", + "description": "opencode api" }, "paths": { + "/auth/{providerID}": { + "put": { + "tags": ["control"], + "operationId": "auth.set", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully set authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully set authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Set authentication credentials", + "summary": "Set auth credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auth" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["control"], + "operationId": "auth.remove", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully removed authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove authentication credentials", + "summary": "Remove auth credentials", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] + } + }, + "/log": { + "post": { + "tags": ["control"], + "operationId": "app.log", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Log entry written successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Log entry written successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Write a log entry to the server logs with specified level and metadata.", + "summary": "Write log", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name for the log entry" + }, + "level": { + "type": "string", + "enum": ["debug", "info", "error", "warn"], + "description": "Log level" + }, + "message": { + "type": "string", + "description": "Log message" + }, + "extra": { + "type": "object" + } + }, + "required": ["service", "level", "message"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + } + ] + } + }, "/global/health": { "get": { + "tags": ["global"], "operationId": "global.health", - "summary": "Get health", - "description": "Get health information about the OpenCode server.", + "parameters": [], "responses": { "200": { "description": "Health information", @@ -21,18 +206,22 @@ "properties": { "healthy": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["healthy", "version"] + "required": ["healthy", "version"], + "additionalProperties": false, + "description": "Health information" } } } } }, + "description": "Get health information about the OpenCode server.", + "summary": "Get health", "x-codeSamples": [ { "lang": "js", @@ -43,9 +232,9 @@ }, "/global/event": { "get": { + "tags": ["global"], "operationId": "global.event", - "summary": "Get global events", - "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "parameters": [], "responses": { "200": { "description": "Event stream", @@ -58,6 +247,8 @@ } } }, + "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "summary": "Get global events", "x-codeSamples": [ { "lang": "js", @@ -68,9 +259,9 @@ }, "/global/config": { "get": { + "tags": ["global"], "operationId": "global.config.get", - "summary": "Get global configuration", - "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Get global config info", @@ -83,6 +274,8 @@ } } }, + "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "summary": "Get global configuration", "x-codeSamples": [ { "lang": "js", @@ -91,9 +284,9 @@ ] }, "patch": { + "tags": ["global"], "operationId": "global.config.update", - "summary": "Update global configuration", - "description": "Update global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Successfully updated global config", @@ -116,6 +309,8 @@ } } }, + "description": "Update global OpenCode configuration settings and preferences.", + "summary": "Update global configuration", "requestBody": { "content": { "application/json": { @@ -135,21 +330,24 @@ }, "/global/dispose": { "post": { + "tags": ["global"], "operationId": "global.dispose", - "summary": "Dispose instance", - "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "parameters": [], "responses": { "200": { "description": "Global disposed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Global disposed" } } } } }, + "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "summary": "Dispose instance", "x-codeSamples": [ { "lang": "js", @@ -160,9 +358,9 @@ }, "/global/upgrade": { "post": { + "tags": ["global"], "operationId": "global.upgrade", - "summary": "Upgrade opencode", - "description": "Upgrade opencode to the specified version or latest if not specified.", + "parameters": [], "responses": { "200": { "description": "Upgrade result", @@ -175,28 +373,31 @@ "properties": { "success": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["success", "version"] + "required": ["success", "version"], + "additionalProperties": false }, { "type": "object", "properties": { "success": { "type": "boolean", - "const": false + "enum": [false] }, "error": { "type": "string" } }, - "required": ["success", "error"] + "required": ["success", "error"], + "additionalProperties": false } - ] + ], + "description": "Upgrade result" } } } @@ -212,6 +413,8 @@ } } }, + "description": "Upgrade opencode to the specified version or latest if not specified.", + "summary": "Upgrade opencode", "requestBody": { "content": { "application/json": { @@ -221,7 +424,8 @@ "target": { "type": "string" } - } + }, + "additionalProperties": false } } } @@ -234,1275 +438,72 @@ ] } }, - "/auth/{providerID}": { - "put": { - "operationId": "auth.set", - "summary": "Set auth credentials", - "description": "Set authentication credentials", - "responses": { - "200": { - "description": "Successfully set authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Auth" - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "auth.remove", - "summary": "Remove auth credentials", - "description": "Remove authentication credentials", - "responses": { - "200": { - "description": "Successfully removed authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" - } - ] - } - }, - "/log": { - "post": { - "operationId": "app.log", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Write log", - "description": "Write a log entry to the server logs with specified level and metadata.", - "responses": { - "200": { - "description": "Log entry written successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "service": { - "description": "Service name for the log entry", - "type": "string" - }, - "level": { - "description": "Log level", - "type": "string", - "enum": ["debug", "info", "error", "warn"] - }, - "message": { - "description": "Log message", - "type": "string" - }, - "extra": { - "description": "Additional metadata for the log entry", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["service", "level", "message"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/adaptor": { + "/event": { "get": { - "operationId": "experimental.workspace.adaptor.list", + "tags": ["event"], + "operationId": "event.subscribe", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List workspace adaptors", - "description": "List all available workspace adaptors for the current project.", "responses": { "200": { - "description": "Workspace adaptors", + "description": "Event stream", "content": { - "application/json": { + "text/event-stream": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": ["type", "name", "description"] - } + "$ref": "#/components/schemas/Event" } } } } }, + "description": "Get events", + "summary": "Subscribe to events", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace": { - "post": { - "operationId": "experimental.workspace.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create workspace", - "description": "Create a workspace for the current project.", - "responses": { - "200": { - "description": "Workspace created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - } - }, - "required": ["type", "branch", "extra"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" - } - ] - }, - "get": { - "operationId": "experimental.workspace.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List workspaces", - "description": "List all workspaces.", - "responses": { - "200": { - "description": "Workspaces", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/status": { - "get": { - "operationId": "experimental.workspace.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Workspace status", - "description": "Get connection status for workspaces in the current project.", - "responses": { - "200": { - "description": "Workspace status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}": { - "delete": { - "operationId": "experimental.workspace.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Remove workspace", - "description": "Remove an existing workspace.", - "responses": { - "200": { - "description": "Workspace removed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}/session-restore": { - "post": { - "operationId": "experimental.workspace.sessionRestore", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Restore session into workspace", - "description": "Replay a session's sync events into the target workspace in batches.", - "responses": { - "200": { - "description": "Session replay started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["total"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" - } - ] - } - }, - "/project": { - "get": { - "operationId": "project.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List all projects", - "description": "Get a list of projects that have been opened with OpenCode.", - "responses": { - "200": { - "description": "List of projects", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Project" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" - } - ] - } - }, - "/project/current": { - "get": { - "operationId": "project.current", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get current project", - "description": "Retrieve the currently active project that OpenCode is working with.", - "responses": { - "200": { - "description": "Current project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" - } - ] - } - }, - "/project/git/init": { - "post": { - "operationId": "project.initGit", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Initialize git repository", - "description": "Create a git repository for the current project and return the refreshed project info.", - "responses": { - "200": { - "description": "Project information after git initialization", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" - } - ] - } - }, - "/project/{projectID}": { - "patch": { - "operationId": "project.update", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "projectID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Update project", - "description": "Update project properties such as name, icon, and commands.", - "responses": { - "200": { - "description": "Updated project information", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" - } - ] - } - }, - "/pty/shells": { - "get": { - "operationId": "pty.shells", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List available shells", - "description": "Get a list of available shells on the system.", - "responses": { - "200": { - "description": "List of shells", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "name": { - "type": "string" - }, - "acceptable": { - "type": "boolean" - } - }, - "required": ["path", "name", "acceptable"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" - } - ] - } - }, - "/pty": { - "get": { - "operationId": "pty.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List PTY sessions", - "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - "responses": { - "200": { - "description": "List of sessions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pty" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" - } - ] - }, - "post": { - "operationId": "pty.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create PTY session", - "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - "responses": { - "200": { - "description": "Created session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "title": { - "type": "string" - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" - } - ] - } - }, - "/pty/{ptyID}": { - "get": { - "operationId": "pty.get", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Get PTY session", - "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Session info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" - } - ] - }, - "put": { - "operationId": "pty.update", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Update PTY session", - "description": "Update properties of an existing pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "size": { - "type": "object", - "properties": { - "rows": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "cols": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["rows", "cols"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "pty.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Remove PTY session", - "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", - "responses": { - "200": { - "description": "Session removed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" - } - ] - } - }, - "/pty/{ptyID}/connect": { - "get": { - "operationId": "pty.connect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "ptyID", - "schema": { - "type": "string", - "pattern": "^pty.*" - }, - "required": true - } - ], - "summary": "Connect to PTY session", - "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - "responses": { - "200": { - "description": "Connected session", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" } ] } }, "/config": { "get": { + "tags": ["config"], "operationId": "config.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get configuration", - "description": "Retrieve the current OpenCode configuration settings and preferences.", "responses": { "200": { "description": "Get config info", @@ -1515,6 +516,8 @@ } } }, + "description": "Retrieve the current OpenCode configuration settings and preferences.", + "summary": "Get configuration", "x-codeSamples": [ { "lang": "js", @@ -1523,25 +526,26 @@ ] }, "patch": { + "tags": ["config"], "operationId": "config.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Update configuration", - "description": "Update OpenCode configuration settings and preferences.", "responses": { "200": { "description": "Successfully updated config", @@ -1564,6 +568,8 @@ } } }, + "description": "Update OpenCode configuration settings and preferences.", + "summary": "Update configuration", "requestBody": { "content": { "application/json": { @@ -1583,25 +589,26 @@ }, "/config/providers": { "get": { + "tags": ["config"], "operationId": "config.providers", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List config providers", - "description": "Get a list of all configured AI providers and their default models.", "responses": { "200": { "description": "List of providers", @@ -1618,20 +625,21 @@ }, "default": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["providers", "default"] + "required": ["providers", "default"], + "additionalProperties": false, + "description": "List of providers" } } } } }, + "description": "Get a list of all configured AI providers and their default models.", + "summary": "List config providers", "x-codeSamples": [ { "lang": "js", @@ -1642,25 +650,26 @@ }, "/experimental/console": { "get": { + "tags": ["experimental"], "operationId": "experimental.console.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get active Console provider metadata", - "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", "responses": { "200": { "description": "Active Console provider metadata", @@ -1673,6 +682,8 @@ } } }, + "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", + "summary": "Get active Console provider metadata", "x-codeSamples": [ { "lang": "js", @@ -1683,25 +694,26 @@ }, "/experimental/console/orgs": { "get": { + "tags": ["experimental"], "operationId": "experimental.console.listOrgs", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List switchable Console orgs", - "description": "Get the available Console orgs across logged-in accounts, including the current active org.", "responses": { "200": { "description": "Switchable Console orgs", @@ -1734,16 +746,21 @@ "type": "boolean" } }, - "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"] + "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"], + "additionalProperties": false } } }, - "required": ["orgs"] + "required": ["orgs"], + "additionalProperties": false, + "description": "Switchable Console orgs" } } } } }, + "description": "Get the available Console orgs across logged-in accounts, including the current active org.", + "summary": "List switchable Console orgs", "x-codeSamples": [ { "lang": "js", @@ -1754,37 +771,41 @@ }, "/experimental/console/switch": { "post": { + "tags": ["experimental"], "operationId": "experimental.console.switchOrg", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Switch active Console org", - "description": "Persist a new active Console account/org selection for the current local OpenCode state.", "responses": { "200": { "description": "Switch success", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Switch success" } } } } }, + "description": "Persist a new active Console account/org selection for the current local OpenCode state.", + "summary": "Switch active Console org", "requestBody": { "content": { "application/json": { @@ -1798,7 +819,8 @@ "type": "string" } }, - "required": ["accountID", "orgID"] + "required": ["accountID", "orgID"], + "additionalProperties": false } } } @@ -1811,94 +833,44 @@ ] } }, - "/experimental/tool/ids": { - "get": { - "operationId": "tool.ids", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List tool IDs", - "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - "responses": { - "200": { - "description": "Tool IDs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolIDs" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" - } - ] - } - }, "/experimental/tool": { "get": { + "tags": ["experimental"], "operationId": "tool.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "provider", + "in": "query", "schema": { "type": "string" }, "required": true }, { - "in": "query", "name": "model", + "in": "query", "schema": { "type": "string" }, "required": true } ], - "summary": "List tools", - "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", "responses": { "200": { "description": "Tools", @@ -1921,6 +893,8 @@ } } }, + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "summary": "List tools", "x-codeSamples": [ { "lang": "js", @@ -1929,27 +903,128 @@ ] } }, - "/experimental/worktree": { - "post": { - "operationId": "worktree.create", + "/experimental/tool/ids": { + "get": { + "tags": ["experimental"], + "operationId": "tool.ids", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Tool IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ToolIDs" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "summary": "List tool IDs", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + } + ] + } + }, + "/experimental/worktree": { + "get": { + "tags": ["experimental"], + "operationId": "worktree.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of worktree directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of worktree directories" + } + } + } + } + }, + "description": "List all sandbox worktrees for the current project.", + "summary": "List worktrees", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["experimental"], + "operationId": "worktree.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create worktree", - "description": "Create a new git worktree for the current project and run any configured startup scripts.", "responses": { "200": { "description": "Worktree created", @@ -1972,6 +1047,8 @@ } } }, + "description": "Create a new git worktree for the current project and run any configured startup scripts.", + "summary": "Create worktree", "requestBody": { "content": { "application/json": { @@ -1988,75 +1065,35 @@ } ] }, - "get": { - "operationId": "worktree.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List worktrees", - "description": "List all sandbox worktrees for the current project.", - "responses": { - "200": { - "description": "List of worktree directories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" - } - ] - }, "delete": { + "tags": ["experimental"], "operationId": "worktree.remove", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Remove worktree", - "description": "Remove a git worktree and delete its branch.", "responses": { "200": { "description": "Worktree removed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Worktree removed" } } } @@ -2072,6 +1109,8 @@ } } }, + "description": "Remove a git worktree and delete its branch.", + "summary": "Remove worktree", "requestBody": { "content": { "application/json": { @@ -2091,32 +1130,34 @@ }, "/experimental/worktree/reset": { "post": { + "tags": ["experimental"], "operationId": "worktree.reset", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Reset worktree", - "description": "Reset a worktree branch to the primary default branch.", "responses": { "200": { "description": "Worktree reset", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Worktree reset" } } } @@ -2132,6 +1173,8 @@ } } }, + "description": "Reset a worktree branch to the primary default branch.", + "summary": "Reset worktree", "requestBody": { "content": { "application/json": { @@ -2151,26 +1194,28 @@ }, "/experimental/session": { "get": { + "tags": ["experimental"], "operationId": "experimental.session.list", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by project directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "roots", + "in": "query", "schema": { "anyOf": [ { @@ -2182,43 +1227,43 @@ } ] }, - "description": "Only return root sessions (no parentID)" + "required": false }, { - "in": "query", "name": "start", + "in": "query", "schema": { "type": "number" }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "cursor", + "in": "query", "schema": { "type": "number" }, - "description": "Return sessions updated before this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "search", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" + "required": false }, { - "in": "query", "name": "limit", + "in": "query", "schema": { "type": "number" }, - "description": "Maximum number of sessions to return" + "required": false }, { - "in": "query", "name": "archived", + "in": "query", "schema": { "anyOf": [ { @@ -2230,11 +1275,9 @@ } ] }, - "description": "Include archived sessions (default false)" + "required": false } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", "responses": { "200": { "description": "List of sessions", @@ -2244,12 +1287,15 @@ "type": "array", "items": { "$ref": "#/components/schemas/GlobalSession" - } + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", @@ -2260,25 +1306,26 @@ }, "/experimental/resource": { "get": { + "tags": ["experimental"], "operationId": "experimental.resource.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get MCP resources", - "description": "Get all available MCP resources from connected servers. Optionally filter by name.", "responses": { "200": { "description": "MCP resources", @@ -2286,17 +1333,17 @@ "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/McpResource" - } + }, + "description": "MCP resources" } } } } }, + "description": "Get all available MCP resources from connected servers. Optionally filter by name.", + "summary": "Get MCP resources", "x-codeSamples": [ { "lang": "js", @@ -2305,45 +1352,2838 @@ ] } }, - "/session": { + "/find": { "get": { - "operationId": "session.list", + "tags": ["file"], + "operationId": "find.text", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "pattern", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Matches", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "lines": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "line_number": { + "type": "integer", + "minimum": 0 + }, + "absolute_offset": { + "type": "integer", + "minimum": 0 + }, + "submatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["match", "start", "end"], + "additionalProperties": false + } + } + }, + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"], + "additionalProperties": false + }, + "description": "Matches" + } + } + } + } + }, + "description": "Search for text patterns across files in the project using ripgrep.", + "summary": "Find text", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + } + ] + } + }, + "/find/file": { + "get": { + "tags": ["file"], + "operationId": "find.files", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "dirs", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["file", "directory"] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false + } + ], + "responses": { + "200": { + "description": "File paths", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File paths" + } + } + } + } + }, + "description": "Search for files or directories by name or pattern in the project directory.", + "summary": "Find files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + } + ] + } + }, + "/find/symbol": { + "get": { + "tags": ["file"], + "operationId": "find.symbols", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Symbols", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + }, + "description": "Symbols" + } + } + } + } + }, + "description": "Search for workspace symbols like functions, classes, and variables using LSP.", + "summary": "Find symbols", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + } + ] + } + }, + "/file": { + "get": { + "tags": ["file"], + "operationId": "file.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Files and directories", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileNode" + }, + "description": "Files and directories" + } + } + } + } + }, + "description": "List files and directories in a specified path.", + "summary": "List files", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + } + ] + } + }, + "/file/content": { + "get": { + "tags": ["file"], + "operationId": "file.read", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "File content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileContent" + } + } + } + } + }, + "description": "Read the content of a specified file.", + "summary": "Read file", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + } + ] + } + }, + "/file/status": { + "get": { + "tags": ["file"], + "operationId": "file.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + }, + "description": "File status" + } + } + } + } + }, + "description": "Get the git status of all files in the project.", + "summary": "Get file status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + } + ] + } + }, + "/instance/dispose": { + "post": { + "tags": ["instance"], + "operationId": "instance.dispose", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Instance disposed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Instance disposed" + } + } + } + } + }, + "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", + "summary": "Dispose instance", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + } + ] + } + }, + "/path": { + "get": { + "tags": ["instance"], + "operationId": "path.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Path", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Path" + } + } + } + } + }, + "description": "Retrieve the current working directory and related path information for the OpenCode instance.", + "summary": "Get paths", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + } + ] + } + }, + "/vcs": { + "get": { + "tags": ["instance"], + "operationId": "vcs.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "VCS info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsInfo" + } + } + } + } + }, + "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", + "summary": "Get VCS info", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" + } + ] + } + }, + "/vcs/diff": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "mode", + "in": "query", + "schema": { + "type": "string", + "enum": ["git", "branch"] + }, + "required": true + } + ], + "responses": { + "200": { + "description": "VCS diff", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileDiff" + }, + "description": "VCS diff" + } + } + } + } + }, + "description": "Retrieve the current git diff for the working tree or against the default branch.", + "summary": "Get VCS diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" + } + ] + } + }, + "/command": { + "get": { + "tags": ["instance"], + "operationId": "command.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of commands", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + }, + "description": "List of commands" + } + } + } + } + }, + "description": "Get a list of all available commands in the OpenCode system.", + "summary": "List commands", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + } + ] + } + }, + "/agent": { + "get": { + "tags": ["instance"], + "operationId": "app.agents", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of agents", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + }, + "description": "List of agents" + } + } + } + } + }, + "description": "Get a list of all available AI agents in the OpenCode system.", + "summary": "List agents", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + } + ] + } + }, + "/skill": { + "get": { + "tags": ["instance"], + "operationId": "app.skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of skills", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["name", "description", "location", "content"], + "additionalProperties": false + }, + "description": "List of skills" + } + } + } + } + }, + "description": "Get a list of all available skills in the OpenCode system.", + "summary": "List skills", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + } + ] + } + }, + "/lsp": { + "get": { + "tags": ["instance"], + "operationId": "lsp.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "LSP server status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LSPStatus" + }, + "description": "LSP server status" + } + } + } + } + }, + "description": "Get LSP server status", + "summary": "Get LSP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + } + ] + } + }, + "/formatter": { + "get": { + "tags": ["instance"], + "operationId": "formatter.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Formatter status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FormatterStatus" + }, + "description": "Formatter status" + } + } + } + } + }, + "description": "Get formatter status", + "summary": "Get formatter status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + } + ] + } + }, + "/mcp": { + "get": { + "tags": ["mcp"], + "operationId": "mcp.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP server status", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server status" + } + } + } + } + }, + "description": "Get the status of all Model Context Protocol (MCP) servers.", + "summary": "Get MCP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + } + ] + }, + "post": { + "tags": ["mcp"], + "operationId": "mcp.add", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP server added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server added successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", + "summary": "Add MCP server", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "required": ["name", "config"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.start", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth flow started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string" + }, + "oauthState": { + "type": "string" + } + }, + "required": ["authorizationUrl", "oauthState"], + "additionalProperties": false, + "description": "OAuth flow started" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + "summary": "Start MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["mcp"], + "operationId": "mcp.auth.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth credentials removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["success"], + "additionalProperties": false, + "description": "OAuth credentials removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Remove OAuth credentials for an MCP server.", + "summary": "Remove MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/callback": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.callback", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + "summary": "Complete MCP OAuth", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + } + }, + "required": ["code"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/auth/authenticate": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.authenticate", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth authentication completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Start OAuth flow and wait for callback (opens browser).", + "summary": "Authenticate MCP OAuth", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/connect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.connect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server connected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "MCP server connected successfully" + } + } + } + } + }, + "description": "Connect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/disconnect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.disconnect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server disconnected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "MCP server disconnected successfully" + } + } + } + } + }, + "description": "Disconnect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + } + ] + } + }, + "/project": { + "get": { + "tags": ["project"], + "operationId": "project.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + }, + "description": "List of projects" + } + } + } + } + }, + "description": "Get a list of projects that have been opened with OpenCode.", + "summary": "List all projects", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + } + ] + } + }, + "/project/current": { + "get": { + "tags": ["project"], + "operationId": "project.current", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Current project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "description": "Retrieve the currently active project that OpenCode is working with.", + "summary": "Get current project", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + } + ] + } + }, + "/project/git/init": { + "post": { + "tags": ["project"], + "operationId": "project.initGit", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Project information after git initialization", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "description": "Create a git repository for the current project and return the refreshed project info.", + "summary": "Initialize git repository", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" + } + ] + } + }, + "/project/{projectID}": { + "patch": { + "tags": ["project"], + "operationId": "project.update", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "projectID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated project information", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Update project properties such as name, icon, and commands.", + "summary": "Update project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + } + ] + } + }, + "/pty/shells": { + "get": { + "tags": ["pty"], + "operationId": "pty.shells", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of shells", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "acceptable": { + "type": "boolean" + } + }, + "required": ["path", "name", "acceptable"], + "additionalProperties": false + }, + "description": "List of shells" + } + } + } + } + }, + "description": "Get a list of available shells on the system.", + "summary": "List available shells", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" + } + ] + } + }, + "/pty": { + "get": { + "tags": ["pty"], + "operationId": "pty.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of sessions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + }, + "description": "List of sessions" + } + } + } + } + }, + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "summary": "List PTY sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["pty"], + "operationId": "pty.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Created session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}": { + "get": { + "tags": ["pty"], + "operationId": "pty.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "summary": "Get PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + } + ] + }, + "put": { + "tags": ["pty"], + "operationId": "pty.update", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pty" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "cols": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["pty"], + "operationId": "pty.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session removed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Session removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "summary": "Remove PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect-token": { + "post": { + "tags": ["pty"], + "operationId": "pty.connectToken", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "WebSocket connect token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false, + "description": "WebSocket connect token" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create a short-lived ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connectToken({\n ...\n})" + } + ] + } + }, + "/question": { + "get": { + "tags": ["question"], + "operationId": "question.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending questions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionRequest" + }, + "description": "List of pending questions" + } + } + } + } + }, + "description": "Get all pending question requests across all sessions.", + "summary": "List pending questions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reply": { + "post": { + "tags": ["question"], + "operationId": "question.reply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Question answered successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Question answered successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Provide answers to a question request from the AI assistant.", + "summary": "Reply to question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": ["answers"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reject": { + "post": { + "tags": ["question"], + "operationId": "question.reject", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^que.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Question rejected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Question rejected successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Reject a question request from the AI assistant.", + "summary": "Reject question request", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + } + ] + } + }, + "/permission": { + "get": { + "tags": ["permission"], + "operationId": "permission.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending permissions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PermissionRequest" + }, + "description": "List of pending permissions" + } + } + } + } + }, + "description": "Get all pending permission requests across all sessions.", + "summary": "List pending permissions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + } + ] + } + }, + "/permission/{requestID}/reply": { + "post": { + "tags": ["permission"], + "operationId": "permission.reply", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "requestID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Permission processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Permission processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + }, + "message": { + "type": "string" + } + }, + "required": ["reply"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" + } + ] + } + }, + "/provider": { + "get": { + "tags": ["provider"], + "operationId": "provider.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["all", "default", "connected"], + "additionalProperties": false, + "description": "List of providers" + } + } + } + } + }, + "description": "Get a list of all available AI providers, including both available and connected ones.", + "summary": "List providers", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + } + ] + } + }, + "/provider/auth": { + "get": { + "tags": ["provider"], + "operationId": "provider.auth", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Provider auth methods", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethod" + } + }, + "description": "Provider auth methods" + } + } + } + } + }, + "description": "Retrieve available authentication methods for all AI providers.", + "summary": "Get provider auth methods", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/authorize": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.authorize", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Authorization URL and method", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderAuthAuthorization" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Start the OAuth authorization flow for a provider.", + "summary": "Start OAuth authorization", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["method"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + } + ] + } + }, + "/provider/{providerID}/oauth/callback": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.callback", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth callback processed successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "OAuth callback processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Handle the OAuth callback from a provider after user authorization.", + "summary": "Handle OAuth callback", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "code": { + "type": "string" + } + }, + "required": ["method"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + } + ] + } + }, + "/session": { + "get": { + "tags": ["session"], + "operationId": "session.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "scope", + "in": "query", "schema": { "type": "string", "enum": ["project"] }, - "description": "List all sessions for the current project" + "required": false }, { - "in": "query", "name": "path", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by project-relative path" + "required": false }, { - "in": "query", "name": "roots", + "in": "query", "schema": { "anyOf": [ { @@ -2355,35 +4195,33 @@ } ] }, - "description": "Only return root sessions (no parentID)" + "required": false }, { - "in": "query", "name": "start", + "in": "query", "schema": { "type": "number" }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + "required": false }, { - "in": "query", "name": "search", + "in": "query", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" + "required": false }, { - "in": "query", "name": "limit", + "in": "query", "schema": { "type": "number" }, - "description": "Maximum number of sessions to return" + "required": false } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", "responses": { "200": { "description": "List of sessions", @@ -2393,12 +4231,15 @@ "type": "array", "items": { "$ref": "#/components/schemas/Session" - } + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", @@ -2407,25 +4248,26 @@ ] }, "post": { + "tags": ["session"], "operationId": "session.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create session", - "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", "responses": { "200": { "description": "Successfully created session", @@ -2448,6 +4290,8 @@ } } }, + "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + "summary": "Create session", "requestBody": { "content": { "application/json": { @@ -2455,20 +4299,38 @@ "type": "object", "properties": { "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" } - } + }, + "additionalProperties": false } } } @@ -2483,25 +4345,26 @@ }, "/session/status": { "get": { + "tags": ["session"], "operationId": "session.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get session status", - "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", "responses": { "200": { "description": "Get session status", @@ -2509,12 +4372,10 @@ "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/SessionStatus" - } + }, + "description": "Get session status" } } } @@ -2530,6 +4391,8 @@ } } }, + "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", + "summary": "Get session status", "x-codeSamples": [ { "lang": "js", @@ -2540,25 +4403,28 @@ }, "/session/{sessionID}": { "get": { + "tags": ["session"], "operationId": "session.get", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2566,9 +4432,6 @@ "required": true } ], - "summary": "Get session", - "description": "Retrieve detailed information about a specific OpenCode session.", - "tags": ["Session"], "responses": { "200": { "description": "Get session", @@ -2601,6 +4464,8 @@ } } }, + "description": "Retrieve detailed information about a specific OpenCode session.", + "summary": "Get session", "x-codeSamples": [ { "lang": "js", @@ -2609,25 +4474,28 @@ ] }, "delete": { + "tags": ["session"], "operationId": "session.delete", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2635,15 +4503,14 @@ "required": true } ], - "summary": "Delete session", - "description": "Delete a session and permanently remove all associated data, including messages and history.", "responses": { "200": { "description": "Successfully deleted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted session" } } } @@ -2669,6 +4536,8 @@ } } }, + "description": "Delete a session and permanently remove all associated data, including messages and history.", + "summary": "Delete session", "x-codeSamples": [ { "lang": "js", @@ -2677,25 +4546,28 @@ ] }, "patch": { + "tags": ["session"], "operationId": "session.update", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2703,8 +4575,6 @@ "required": true } ], - "summary": "Update session", - "description": "Update properties of an existing session, such as title or other metadata.", "responses": { "200": { "description": "Successfully updated session", @@ -2737,6 +4607,8 @@ } } }, + "description": "Update properties of an existing session, such as title or other metadata.", + "summary": "Update session", "requestBody": { "content": { "application/json": { @@ -2755,9 +4627,11 @@ "archived": { "type": "number" } - } + }, + "additionalProperties": false } - } + }, + "additionalProperties": false } } } @@ -2772,25 +4646,28 @@ }, "/session/{sessionID}/children": { "get": { + "tags": ["session"], "operationId": "session.children", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2798,9 +4675,6 @@ "required": true } ], - "summary": "Get session children", - "tags": ["Session"], - "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { "description": "List of children", @@ -2810,7 +4684,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Session" - } + }, + "description": "List of children" } } } @@ -2836,6 +4711,8 @@ } } }, + "description": "Retrieve all child sessions that were forked from the specified parent session.", + "summary": "Get session children", "x-codeSamples": [ { "lang": "js", @@ -2846,25 +4723,28 @@ }, "/session/{sessionID}/todo": { "get": { + "tags": ["session"], "operationId": "session.todo", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -2872,8 +4752,6 @@ "required": true } ], - "summary": "Get session todos", - "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", "responses": { "200": { "description": "Todo list", @@ -2883,7 +4761,8 @@ "type": "array", "items": { "$ref": "#/components/schemas/Todo" - } + }, + "description": "Todo list" } } } @@ -2909,6 +4788,8 @@ } } }, + "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", + "summary": "Get session todos", "x-codeSamples": [ { "lang": "js", @@ -2917,43 +4798,145 @@ ] } }, - "/session/{sessionID}/init": { - "post": { - "operationId": "session.init", + "/session/{sessionID}/diff": { + "get": { + "tags": ["session"], + "operationId": "session.diff", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + }, + { + "name": "messageID", + "in": "query", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": false } ], - "summary": "Initialize session", - "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", "responses": { "200": { - "description": "200", + "description": "Successfully retrieved diff", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + }, + "description": "Successfully retrieved diff" + } + } + } + } + }, + "description": "Get the file changes (diff) that resulted from a specific user message in the session.", + "summary": "Get message diff", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message": { + "get": { + "tags": ["session"], + "operationId": "session.messages", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "required": false + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "List of messages", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false + }, + "description": "List of messages" } } } @@ -2979,57 +4962,369 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["modelID", "providerID", "messageID"] - } - } - } - }, + "description": "Retrieve all messages in a session, including user prompts and AI responses.", + "summary": "Get session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" } ] - } - }, - "/session/{sessionID}/fork": { + }, "post": { - "operationId": "session.fork", + "tags": ["session"], + "operationId": "session.prompt", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create and send a new message to a session, streaming the AI response.", + "summary": "Send message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": ["parts"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/message/{messageID}": { + "get": { + "tags": ["session"], + "operationId": "session.message", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Retrieve a specific message from a session by its message ID.", + "summary": "Get message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["session"], + "operationId": "session.deleteMessage", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully deleted message", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully deleted message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + "summary": "Delete message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/fork": { + "post": { + "tags": ["session"], + "operationId": "session.fork", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3037,8 +5332,6 @@ "required": true } ], - "summary": "Fork session", - "description": "Create a new session by forking an existing session at a specific message point.", "responses": { "200": { "description": "200", @@ -3051,6 +5344,8 @@ } } }, + "description": "Create a new session by forking an existing session at a specific message point.", + "summary": "Fork session", "requestBody": { "content": { "application/json": { @@ -3058,10 +5353,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } - } + }, + "additionalProperties": false } } } @@ -3076,25 +5371,28 @@ }, "/session/{sessionID}/abort": { "post": { + "tags": ["session"], "operationId": "session.abort", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3102,15 +5400,14 @@ "required": true } ], - "summary": "Abort session", - "description": "Abort an active session and stop any ongoing AI processing or command execution.", "responses": { "200": { "description": "Aborted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Aborted session" } } } @@ -3136,6 +5433,8 @@ } } }, + "description": "Abort an active session and stop any ongoing AI processing or command execution.", + "summary": "Abort session", "x-codeSamples": [ { "lang": "js", @@ -3144,27 +5443,126 @@ ] } }, - "/session/{sessionID}/share": { + "/session/{sessionID}/init": { "post": { - "operationId": "session.share", + "tags": ["session"], + "operationId": "session.init", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "200" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + "summary": "Initialize session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["modelID", "providerID", "messageID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/share": { + "post": { + "tags": ["session"], + "operationId": "session.share", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3172,8 +5570,6 @@ "required": true } ], - "summary": "Share session", - "description": "Create a shareable link for a session, allowing others to view the conversation.", "responses": { "200": { "description": "Successfully shared session", @@ -3206,6 +5602,8 @@ } } }, + "description": "Create a shareable link for a session, allowing others to view the conversation.", + "summary": "Share session", "x-codeSamples": [ { "lang": "js", @@ -3214,25 +5612,28 @@ ] }, "delete": { + "tags": ["session"], "operationId": "session.unshare", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3240,8 +5641,6 @@ "required": true } ], - "summary": "Unshare session", - "description": "Remove the shareable link for a session, making it private again.", "responses": { "200": { "description": "Successfully unshared session", @@ -3274,6 +5673,8 @@ } } }, + "description": "Remove the shareable link for a session, making it private again.", + "summary": "Unshare session", "x-codeSamples": [ { "lang": "js", @@ -3282,88 +5683,30 @@ ] } }, - "/session/{sessionID}/diff": { - "get": { - "operationId": "session.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "messageID", - "schema": { - "type": "string", - "pattern": "^msg.*" - } - } - ], - "summary": "Get message diff", - "description": "Get the file changes (diff) that resulted from a specific user message in the session.", - "responses": { - "200": { - "description": "Successfully retrieved diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" - } - ] - } - }, "/session/{sessionID}/summarize": { "post": { + "tags": ["session"], "operationId": "session.summarize", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3371,15 +5714,14 @@ "required": true } ], - "summary": "Summarize session", - "description": "Generate a concise summary of the session using AI compaction to preserve key information.", "responses": { "200": { "description": "Summarized session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Summarized session" } } } @@ -3405,6 +5747,8 @@ } } }, + "description": "Generate a concise summary of the session using AI compaction to preserve key information.", + "summary": "Summarize session", "requestBody": { "content": { "application/json": { @@ -3418,11 +5762,11 @@ "type": "string" }, "auto": { - "default": false, "type": "boolean" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false } } } @@ -3435,127 +5779,30 @@ ] } }, - "/session/{sessionID}/message": { - "get": { - "operationId": "session.messages", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "description": "Maximum number of messages to return" - }, - { - "in": "query", - "name": "before", - "schema": { - "type": "string" - } - } - ], - "summary": "Get session messages", - "description": "Retrieve all messages in a session, including user prompts and AI responses.", - "responses": { - "200": { - "description": "List of messages", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" - } - ] - }, + "/session/{sessionID}/prompt_async": { "post": { - "operationId": "session.prompt", + "tags": ["session"], + "operationId": "session.prompt_async", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3563,30 +5810,9 @@ "required": true } ], - "summary": "Send message", - "description": "Create and send a new message to a session, streaming the AI response.", "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } + "204": { + "description": "Prompt accepted" }, "400": { "description": "Bad request", @@ -3609,6 +5835,8 @@ } } }, + "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + "summary": "Send async message", "requestBody": { "content": { "application/json": { @@ -3616,8 +5844,7 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "model": { "type": "object", @@ -3629,7 +5856,8 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "agent": { "type": "string" @@ -3638,11 +5866,7 @@ "type": "boolean" }, "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -3676,7 +5900,8 @@ } } }, - "required": ["parts"] + "required": ["parts"], + "additionalProperties": false } } } @@ -3684,53 +5909,190 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" } ] } }, - "/session/{sessionID}/message/{messageID}": { - "get": { - "operationId": "session.message", + "/session/{sessionID}/command": { + "post": { + "tags": ["session"], + "operationId": "session.command", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + } + ], + "responses": { + "200": { + "description": "Created message", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Send a new command to a session for execution by the AI assistant.", + "summary": "Send command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + } + } + }, + "required": ["arguments", "command"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/shell": { + "post": { + "tags": ["session"], + "operationId": "session.shell", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", "in": "path", - "name": "messageID", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^ses.*" }, "required": true } ], - "summary": "Get message", - "description": "Retrieve a specific message from a session by its message ID.", "responses": { "200": { - "description": "Message", + "description": "Created message", "content": { "application/json": { "schema": { @@ -3746,7 +6108,9 @@ } } }, - "required": ["info", "parts"] + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Created message" } } } @@ -3772,33 +6136,240 @@ } } }, + "description": "Execute a shell command within the session context and return the AI's response.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["agent", "command"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" } ] - }, - "delete": { - "operationId": "session.deleteMessage", + } + }, + "/session/{sessionID}/revert": { + "post": { + "tags": ["session"], + "operationId": "session.revert", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", + "summary": "Revert message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/unrevert": { + "post": { + "tags": ["session"], + "operationId": "session.unrevert", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Updated session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Restore all previously reverted messages in a session.", + "summary": "Restore reverted messages", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/permissions/{permissionID}": { + "post": { + "tags": ["session"], + "operationId": "permission.respond", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3806,24 +6377,23 @@ "required": true }, { + "name": "permissionID", "in": "path", - "name": "messageID", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^per.*" }, "required": true } ], - "summary": "Delete message", - "description": "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", "responses": { "200": { - "description": "Successfully deleted message", + "description": "Permission processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Permission processed successfully" } } } @@ -3849,35 +6419,58 @@ } } }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["response"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" } ] } }, "/session/{sessionID}/message/{messageID}/part/{partID}": { "delete": { + "tags": ["session"], "operationId": "part.delete", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3885,8 +6478,8 @@ "required": true }, { - "in": "path", "name": "messageID", + "in": "path", "schema": { "type": "string", "pattern": "^msg.*" @@ -3894,8 +6487,8 @@ "required": true }, { - "in": "path", "name": "partID", + "in": "path", "schema": { "type": "string", "pattern": "^prt.*" @@ -3903,14 +6496,14 @@ "required": true } ], - "description": "Delete a part from a message", "responses": { "200": { "description": "Successfully deleted part", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted part" } } } @@ -3936,6 +6529,7 @@ } } }, + "description": "Delete a part from a message.", "x-codeSamples": [ { "lang": "js", @@ -3944,25 +6538,28 @@ ] }, "patch": { + "tags": ["session"], "operationId": "part.update", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -3970,8 +6567,8 @@ "required": true }, { - "in": "path", "name": "messageID", + "in": "path", "schema": { "type": "string", "pattern": "^msg.*" @@ -3979,8 +6576,8 @@ "required": true }, { - "in": "path", "name": "partID", + "in": "path", "schema": { "type": "string", "pattern": "^prt.*" @@ -3988,7 +6585,6 @@ "required": true } ], - "description": "Update a part in a message", "responses": { "200": { "description": "Successfully updated part", @@ -4021,6 +6617,7 @@ } } }, + "description": "Update a part in a message.", "requestBody": { "content": { "application/json": { @@ -4038,1305 +6635,43 @@ ] } }, - "/session/{sessionID}/prompt_async": { - "post": { - "operationId": "session.prompt_async", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Send async message", - "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - "responses": { - "204": { - "description": "Prompt accepted" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "agent": { - "type": "string" - }, - "noReply": { - "type": "boolean" - }, - "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "system": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextPartInput" - }, - { - "$ref": "#/components/schemas/FilePartInput" - }, - { - "$ref": "#/components/schemas/AgentPartInput" - }, - { - "$ref": "#/components/schemas/SubtaskPartInput" - } - ] - } - } - }, - "required": ["parts"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/command": { - "post": { - "operationId": "session.command", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Send command", - "description": "Send a new command to a session for execution by the AI assistant.", - "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "string" - }, - "arguments": { - "type": "string" - }, - "command": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - } - } - }, - "required": ["arguments", "command"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/shell": { - "post": { - "operationId": "session.shell", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Run shell command", - "description": "Execute a shell command within the session context and return the AI's response.", - "responses": { - "200": { - "description": "Created message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["agent", "command"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/revert": { - "post": { - "operationId": "session.revert", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Revert message", - "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["messageID"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/unrevert": { - "post": { - "operationId": "session.unrevert", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - } - ], - "summary": "Restore reverted messages", - "description": "Restore all previously reverted messages in a session.", - "responses": { - "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" - } - ] - } - }, - "/session/{sessionID}/permissions/{permissionID}": { - "post": { - "operationId": "permission.respond", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "path", - "name": "permissionID", - "schema": { - "type": "string", - "pattern": "^per.*" - }, - "required": true - } - ], - "summary": "Respond to permission", - "deprecated": true, - "description": "Approve or deny a permission request from the AI assistant.", - "responses": { - "200": { - "description": "Permission processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "response": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["response"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" - } - ] - } - }, - "/permission/{requestID}/reply": { - "post": { - "operationId": "permission.reply", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^per.*" - }, - "required": true - } - ], - "summary": "Respond to permission request", - "description": "Approve or deny a permission request from the AI assistant.", - "responses": { - "200": { - "description": "Permission processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - }, - "message": { - "type": "string" - } - }, - "required": ["reply"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" - } - ] - } - }, - "/permission": { - "get": { - "operationId": "permission.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending permissions", - "description": "Get all pending permission requests across all sessions.", - "responses": { - "200": { - "description": "List of pending permissions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionRequest" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" - } - ] - } - }, - "/question": { - "get": { - "operationId": "question.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending questions", - "description": "Get all pending question requests across all sessions.", - "responses": { - "200": { - "description": "List of pending questions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionRequest" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" - } - ] - } - }, - "/question/{requestID}/reply": { - "post": { - "operationId": "question.reply", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^que.*" - }, - "required": true - } - ], - "summary": "Reply to question request", - "description": "Provide answers to a question request from the AI assistant.", - "responses": { - "200": { - "description": "Question answered successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "answers": { - "description": "User answers in order of questions (each answer is an array of selected labels)", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["answers"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" - } - ] - } - }, - "/question/{requestID}/reject": { - "post": { - "operationId": "question.reject", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "requestID", - "schema": { - "type": "string", - "pattern": "^que.*" - }, - "required": true - } - ], - "summary": "Reject question request", - "description": "Reject a question request from the AI assistant.", - "responses": { - "200": { - "description": "Question rejected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" - } - ] - } - }, - "/provider": { - "get": { - "operationId": "provider.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List providers", - "description": "Get a list of all available AI providers, including both available and connected ones.", - "responses": { - "200": { - "description": "List of providers", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "all": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - }, - "default": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "connected": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["all", "default", "connected"] - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" - } - ] - } - }, - "/provider/auth": { - "get": { - "operationId": "provider.auth", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get provider auth methods", - "description": "Retrieve available authentication methods for all AI providers.", - "responses": { - "200": { - "description": "Provider auth methods", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderAuthMethod" - } - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" - } - ] - } - }, - "/provider/{providerID}/oauth/authorize": { - "post": { - "operationId": "provider.oauth.authorize", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true, - "description": "Provider ID" - } - ], - "summary": "OAuth authorize", - "description": "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - "responses": { - "200": { - "description": "Authorization URL and method", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderAuthAuthorization" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "inputs": { - "description": "Prompt inputs", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["method"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" - } - ] - } - }, - "/provider/{providerID}/oauth/callback": { - "post": { - "operationId": "provider.oauth.callback", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true, - "description": "Provider ID" - } - ], - "summary": "OAuth callback", - "description": "Handle the OAuth callback from a provider after user authorization.", - "responses": { - "200": { - "description": "OAuth callback processed successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "code": { - "description": "OAuth authorization code", - "type": "string" - } - }, - "required": ["method"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" - } - ] - } - }, "/sync/start": { "post": { + "tags": ["sync"], "operationId": "sync.start", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Start workspace sync", - "description": "Start sync loops for workspaces in the current project that have active sessions.", "responses": { "200": { "description": "Workspace sync started", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Workspace sync started" } } } } }, + "description": "Start sync loops for workspaces in the current project that have active sessions.", + "summary": "Start workspace sync", "x-codeSamples": [ { "lang": "js", @@ -5347,25 +6682,26 @@ }, "/sync/replay": { "post": { + "tags": ["sync"], "operationId": "sync.replay", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Replay sync events", - "description": "Validate and replay a complete sync event history.", "responses": { "200": { "description": "Replayed sync events", @@ -5378,7 +6714,9 @@ "type": "string" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false, + "description": "Replayed sync events" } } } @@ -5394,6 +6732,8 @@ } } }, + "description": "Validate and replay a complete sync event history.", + "summary": "Replay sync events", "requestBody": { "content": { "application/json": { @@ -5404,8 +6744,8 @@ "type": "string" }, "events": { - "minItems": 1, "type": "array", + "minItems": 1, "items": { "type": "object", "properties": { @@ -5417,25 +6757,22 @@ }, "seq": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "type": { "type": "string" }, "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "aggregateID", "seq", "type", "data"] + "required": ["id", "aggregateID", "seq", "type", "data"], + "additionalProperties": false } } }, - "required": ["directory", "events"] + "required": ["directory", "events"], + "additionalProperties": false } } } @@ -5448,27 +6785,106 @@ ] } }, - "/sync/history": { + "/sync/steal": { "post": { - "operationId": "sync.history.list", + "tags": ["sync"], + "operationId": "sync.steal", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session stolen into workspace", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false, + "description": "Session stolen into workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update a session to belong to the current workspace through the sync event system.", + "summary": "Steal session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})" + } + ] + } + }, + "/sync/history": { + "post": { + "tags": ["sync"], + "operationId": "sync.history.list", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List sync events", - "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", "responses": { "200": { "description": "Sync events", @@ -5486,21 +6902,20 @@ "type": "string" }, "seq": { - "type": "number" + "type": "integer", + "minimum": 0 }, "type": { "type": "string" }, "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "aggregate_id", "seq", "type", "data"] - } + "required": ["id", "aggregate_id", "seq", "type", "data"], + "additionalProperties": false + }, + "description": "Sync events" } } } @@ -5516,18 +6931,16 @@ } } }, + "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + "summary": "List sync events", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } } } @@ -5541,511 +6954,35 @@ ] } }, - "/find": { + "/api/session": { "get": { - "operationId": "find.text", + "tags": ["v2"], + "operationId": "v2.session.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "query", - "name": "pattern", - "schema": { - "type": "string" - }, - "required": true } ], - "summary": "Find text", - "description": "Search for text patterns across files in the project using ripgrep.", "responses": { "200": { - "description": "Matches", + "description": "V2SessionsResponse", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "lines": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "line_number": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "absolute_offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "submatches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "match": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["match", "start", "end"] - } - } - }, - "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" - } - ] - } - }, - "/find/file": { - "get": { - "operationId": "find.files", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "dirs", - "schema": { - "type": "string", - "enum": ["true", "false"] - } - }, - { - "in": "query", - "name": "type", - "schema": { - "type": "string", - "enum": ["file", "directory"] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - } - } - ], - "summary": "Find files", - "description": "Search for files or directories by name or pattern in the project directory.", - "responses": { - "200": { - "description": "File paths", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" - } - ] - } - }, - "/find/symbol": { - "get": { - "operationId": "find.symbols", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "query", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Find symbols", - "description": "Search for workspace symbols like functions, classes, and variables using LSP.", - "responses": { - "200": { - "description": "Symbols", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Symbol" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" - } - ] - } - }, - "/file": { - "get": { - "operationId": "file.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "List files", - "description": "List files and directories in a specified path.", - "responses": { - "200": { - "description": "Files and directories", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileNode" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" - } - ] - } - }, - "/file/content": { - "get": { - "operationId": "file.read", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "path", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Read file", - "description": "Read the content of a specified file.", - "responses": { - "200": { - "description": "File content", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FileContent" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" - } - ] - } - }, - "/file/status": { - "get": { - "operationId": "file.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get file status", - "description": "Get the git status of all files in the project.", - "responses": { - "200": { - "description": "File status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/File" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" - } - ] - } - }, - "/event": { - "get": { - "operationId": "event.subscribe", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Subscribe to events", - "description": "Get events", - "responses": { - "200": { - "description": "Event stream", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/Event" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" - } - ] - } - }, - "/mcp": { - "get": { - "operationId": "mcp.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get MCP status", - "description": "Get the status of all Model Context Protocol (MCP) servers.", - "responses": { - "200": { - "description": "MCP server status", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" - } - ] - }, - "post": { - "operationId": "mcp.add", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Add MCP server", - "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", - "responses": { - "200": { - "description": "MCP server added successfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } + "$ref": "#/components/schemas/V2SessionsResponse" } } } @@ -6061,27 +6998,76 @@ } } }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List v2 sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.list({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": ["v2"], + "operationId": "v2.session.prompt", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session.Message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionMessage" + } + } + } + } + }, + "description": "Create a v2 session message and queue it for the agent loop.", + "summary": "Send v2 message", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "name": { - "type": "string" + "prompt": { + "$ref": "#/components/schemas/Prompt" }, - "config": { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] + "delivery": { + "$ref": "#/components/schemas/SessionDelivery" } }, - "required": ["name", "config"] + "required": ["prompt"], + "additionalProperties": false } } } @@ -6089,187 +7075,197 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.prompt({\n ...\n})" } ] } }, - "/mcp/{name}/auth": { + "/api/session/{sessionID}/compact": { "post": { - "operationId": "mcp.auth.start", + "tags": ["v2"], + "operationId": "v2.session.compact", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, + } + }, + { + "name": "sessionID", "in": "path", - "name": "name", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, "required": true } ], - "summary": "Start MCP OAuth", - "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", "responses": { - "200": { - "description": "OAuth flow started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "authorizationUrl": { - "description": "URL to open in browser for authorization", - "type": "string" - } - }, - "required": ["authorizationUrl"] - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } + "204": { + "description": "" } }, + "description": "Compact a v2 session conversation.", + "summary": "Compact v2 session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" - } - ] - }, - "delete": { - "operationId": "mcp.auth.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true - } - ], - "summary": "Remove MCP OAuth", - "description": "Remove OAuth credentials for an MCP server", - "responses": { - "200": { - "description": "OAuth credentials removed", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "const": true - } - }, - "required": ["success"] - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.compact({\n ...\n})" } ] } }, - "/mcp/{name}/auth/callback": { + "/api/session/{sessionID}/wait": { "post": { - "operationId": "mcp.auth.callback", + "tags": ["v2"], + "operationId": "v2.session.wait", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, + } + }, + { + "name": "sessionID", "in": "path", - "name": "name", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "" + } + }, + "description": "Wait for a v2 session agent loop to become idle.", + "summary": "Wait for v2 session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.wait({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/context": { + "get": { + "tags": ["v2"], + "operationId": "v2.session.context", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, "required": true } ], - "summary": "Complete MCP OAuth", - "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", "responses": { "200": { - "description": "OAuth authentication completed", + "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MCPStatus" + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + } + } + } + } + }, + "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + "summary": "Get v2 session context", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.context({\n ...\n})" + } + ] + } + }, + "/api/session/{sessionID}/message": { + "get": { + "tags": ["v2 messages"], + "operationId": "v2.session.messages", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "V2SessionMessagesResponse", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2SessionMessagesResponse" } } } @@ -6283,235 +7279,48 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "description": "Authorization code from OAuth callback", - "type": "string" - } - }, - "required": ["code"] - } - } } }, + "description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get v2 session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/auth/authenticate": { - "post": { - "operationId": "mcp.auth.authenticate", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true - } - ], - "summary": "Authenticate MCP OAuth", - "description": "Start OAuth flow and wait for callback (opens browser)", - "responses": { - "200": { - "description": "OAuth authentication completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/connect": { - "post": { - "operationId": "mcp.connect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "schema": { - "type": "string" - }, - "required": true - } - ], - "description": "Connect an MCP server", - "responses": { - "200": { - "description": "MCP server connected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" - } - ] - } - }, - "/mcp/{name}/disconnect": { - "post": { - "operationId": "mcp.disconnect", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "name", - "schema": { - "type": "string" - }, - "required": true - } - ], - "description": "Disconnect an MCP server", - "responses": { - "200": { - "description": "MCP server disconnected successfully", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.messages({\n ...\n})" } ] } }, "/tui/append-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.appendPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Append TUI prompt", - "description": "Append prompt to the TUI", "responses": { "200": { "description": "Prompt processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt processed successfully" } } } @@ -6527,6 +7336,8 @@ } } }, + "description": "Append prompt to the TUI.", + "summary": "Append TUI prompt", "requestBody": { "content": { "application/json": { @@ -6537,7 +7348,8 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } } } @@ -6552,37 +7364,41 @@ }, "/tui/open-help": { "post": { + "tags": ["tui"], "operationId": "tui.openHelp", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open help dialog", - "description": "Open the help dialog in the TUI to display user assistance information.", "responses": { "200": { "description": "Help dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Help dialog opened successfully" } } } } }, + "description": "Open the help dialog in the TUI to display user assistance information.", + "summary": "Open help dialog", "x-codeSamples": [ { "lang": "js", @@ -6593,37 +7409,41 @@ }, "/tui/open-sessions": { "post": { + "tags": ["tui"], "operationId": "tui.openSessions", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open sessions dialog", - "description": "Open the session dialog", "responses": { "200": { "description": "Session dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session dialog opened successfully" } } } } }, + "description": "Open the session dialog.", + "summary": "Open sessions dialog", "x-codeSamples": [ { "lang": "js", @@ -6634,37 +7454,41 @@ }, "/tui/open-themes": { "post": { + "tags": ["tui"], "operationId": "tui.openThemes", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open themes dialog", - "description": "Open the theme dialog", "responses": { "200": { "description": "Theme dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Theme dialog opened successfully" } } } } }, + "description": "Open the theme dialog.", + "summary": "Open themes dialog", "x-codeSamples": [ { "lang": "js", @@ -6675,37 +7499,41 @@ }, "/tui/open-models": { "post": { + "tags": ["tui"], "operationId": "tui.openModels", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open models dialog", - "description": "Open the model dialog", "responses": { "200": { "description": "Model dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Model dialog opened successfully" } } } } }, + "description": "Open the model dialog.", + "summary": "Open models dialog", "x-codeSamples": [ { "lang": "js", @@ -6716,37 +7544,41 @@ }, "/tui/submit-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.submitPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI prompt", - "description": "Submit the prompt", "responses": { "200": { "description": "Prompt submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt submitted successfully" } } } } }, + "description": "Submit the prompt.", + "summary": "Submit TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6757,37 +7589,41 @@ }, "/tui/clear-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.clearPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Clear TUI prompt", - "description": "Clear the prompt", "responses": { "200": { "description": "Prompt cleared successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt cleared successfully" } } } } }, + "description": "Clear the prompt.", + "summary": "Clear TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6798,32 +7634,34 @@ }, "/tui/execute-command": { "post": { + "tags": ["tui"], "operationId": "tui.executeCommand", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Execute TUI command", - "description": "Execute a TUI command (e.g. agent_cycle)", "responses": { "200": { "description": "Command executed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Command executed successfully" } } } @@ -6839,6 +7677,8 @@ } } }, + "description": "Execute a TUI command.", + "summary": "Execute TUI command", "requestBody": { "content": { "application/json": { @@ -6849,7 +7689,8 @@ "type": "string" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } } } @@ -6864,37 +7705,41 @@ }, "/tui/show-toast": { "post": { + "tags": ["tui"], "operationId": "tui.showToast", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Show TUI toast", - "description": "Show a toast notification in the TUI", "responses": { "200": { "description": "Toast notification shown successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Toast notification shown successfully" } } } } }, + "description": "Show a toast notification in the TUI.", + "summary": "Show TUI toast", "requestBody": { "content": { "application/json": { @@ -6912,13 +7757,12 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } } } @@ -6933,32 +7777,34 @@ }, "/tui/publish": { "post": { + "tags": ["tui"], "operationId": "tui.publish", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Publish TUI event", - "description": "Publish a TUI event", "responses": { "200": { "description": "Event published successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Event published successfully" } } } @@ -6974,22 +7820,24 @@ } } }, + "description": "Publish a TUI event.", + "summary": "Publish TUI event", "requestBody": { "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" + "$ref": "#/components/schemas/EventTuiPromptAppend" }, { - "$ref": "#/components/schemas/Event.tui.command.execute" + "$ref": "#/components/schemas/EventTuiCommandExecute" }, { - "$ref": "#/components/schemas/Event.tui.toast.show" + "$ref": "#/components/schemas/EventTuiToastShow" }, { - "$ref": "#/components/schemas/Event.tui.session.select" + "$ref": "#/components/schemas/EventTuiSessionSelect" } ] } @@ -7006,32 +7854,34 @@ }, "/tui/select-session": { "post": { + "tags": ["tui"], "operationId": "tui.selectSession", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Select session", - "description": "Navigate the TUI to display the specified session.", "responses": { "200": { "description": "Session selected successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session selected successfully" } } } @@ -7057,6 +7907,8 @@ } } }, + "description": "Navigate the TUI to display the specified session.", + "summary": "Select session", "requestBody": { "content": { "application/json": { @@ -7064,12 +7916,12 @@ "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } } } @@ -7084,25 +7936,26 @@ }, "/tui/control/next": { "get": { + "tags": ["tui"], "operationId": "tui.control.next", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get next TUI request", - "description": "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", "responses": { "200": { "description": "Next TUI request", @@ -7116,12 +7969,16 @@ }, "body": {} }, - "required": ["path", "body"] + "required": ["path", "body"], + "additionalProperties": false, + "description": "Next TUI request" } } } } }, + "description": "Retrieve the next TUI request from the queue for processing.", + "summary": "Get next TUI request", "x-codeSamples": [ { "lang": "js", @@ -7132,37 +7989,41 @@ }, "/tui/control/response": { "post": { + "tags": ["tui"], "operationId": "tui.control.response", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI response", - "description": "Submit a response to the TUI request queue to complete a pending request.", "responses": { "200": { "description": "Response submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Response submitted successfully" } } } } }, + "description": "Submit a response to the TUI request queue to complete a pending request.", + "summary": "Submit TUI response", "requestBody": { "content": { "application/json": { @@ -7178,294 +8039,31 @@ ] } }, - "/instance/dispose": { - "post": { - "operationId": "instance.dispose", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Dispose instance", - "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", - "responses": { - "200": { - "description": "Instance disposed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" - } - ] - } - }, - "/path": { + "/experimental/workspace/adapter": { "get": { - "operationId": "path.get", + "tags": ["workspace"], + "operationId": "experimental.workspace.adapter.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get paths", - "description": "Retrieve the current working directory and related path information for the OpenCode instance.", "responses": { "200": { - "description": "Path", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Path" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" - } - ] - } - }, - "/vcs": { - "get": { - "operationId": "vcs.get", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get VCS info", - "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", - "responses": { - "200": { - "description": "VCS info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VcsInfo" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" - } - ] - } - }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VcsFileDiff" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" - } - ] - } - }, - "/command": { - "get": { - "operationId": "command.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List commands", - "description": "Get a list of all available commands in the OpenCode system.", - "responses": { - "200": { - "description": "List of commands", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Command" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" - } - ] - } - }, - "/agent": { - "get": { - "operationId": "app.agents", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List agents", - "description": "Get a list of all available AI agents in the OpenCode system.", - "responses": { - "200": { - "description": "List of agents", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Agent" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" - } - ] - } - }, - "/skill": { - "get": { - "operationId": "app.skills", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List skills", - "description": "Get a list of all available skills in the OpenCode system.", - "responses": { - "200": { - "description": "List of skills", + "description": "Workspace adapters", "content": { "application/json": { "schema": { @@ -7473,118 +8071,430 @@ "items": { "type": "object", "properties": { + "type": { + "type": "string" + }, "name": { "type": "string" }, "description": { "type": "string" - }, - "location": { - "type": "string" - }, - "content": { - "type": "string" } }, - "required": ["name", "description", "location", "content"] - } + "required": ["type", "name", "description"], + "additionalProperties": false + }, + "description": "Workspace adapters" } } } } }, + "description": "List all available workspace adapters for the current project.", + "summary": "List workspace adapters", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } }, - "/lsp": { + "/experimental/workspace": { "get": { - "operationId": "lsp.status", + "tags": ["workspace"], + "operationId": "experimental.workspace.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get LSP status", - "description": "Get LSP server status", "responses": { "200": { - "description": "LSP server status", + "description": "Workspaces", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/LSPStatus" - } + "$ref": "#/components/schemas/Workspace" + }, + "description": "Workspaces" } } } } }, + "description": "List all workspaces.", + "summary": "List workspaces", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" } ] - } - }, - "/formatter": { - "get": { - "operationId": "formatter.status", + }, + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get formatter status", - "description": "Get formatter status", "responses": { "200": { - "description": "Formatter status", + "description": "Workspace created", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FormatterStatus" - } + "$ref": "#/components/schemas/Workspace" } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Create a workspace for the current project.", + "summary": "Create workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["type", "branch"], + "additionalProperties": false + } + } } }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/status": { + "get": { + "tags": ["workspace"], + "operationId": "experimental.workspace.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Workspace status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, + "description": "Workspace status" + } + } + } + } + }, + "description": "Get connection status for workspaces in the current project.", + "summary": "Workspace status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}": { + "delete": { + "tags": ["workspace"], + "operationId": "experimental.workspace.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Workspace removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove an existing workspace.", + "summary": "Remove workspace", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/warp": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.warp", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Session warped" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Move a session's sync history into the target workspace, or detach it to the local project.", + "summary": "Warp session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["id", "sessionID"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})" + } + ] + } + }, + "/pty/{ptyID}/connect": { + "get": { + "tags": ["pty"], + "operationId": "pty.connect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Connected session", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Connected session" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "summary": "Connect to PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" } ] } @@ -7592,257 +8502,311 @@ }, "components": { "schemas": { - "Project": { + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" + }, + { + "$ref": "#/components/schemas/EventFileEdited" + }, + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartDelta" + }, + { + "$ref": "#/components/schemas/EventPermissionAsked" + }, + { + "$ref": "#/components/schemas/EventPermissionReplied" + }, + { + "$ref": "#/components/schemas/EventSessionDiff" + }, + { + "$ref": "#/components/schemas/EventSessionError" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdated" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed" + }, + { + "$ref": "#/components/schemas/EventPtyCreated" + }, + { + "$ref": "#/components/schemas/EventPtyUpdated" + }, + { + "$ref": "#/components/schemas/EventPtyExited" + }, + { + "$ref": "#/components/schemas/EventPtyDeleted" + }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/EventSessionCreated" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + } + ] + }, + "OAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { + "type": { "type": "string", - "const": "git" + "enum": ["oauth"] }, - "name": { + "refresh": { "type": "string" }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } + "access": { + "type": "string" }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } + "expires": { + "type": "integer", + "minimum": 0 }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "updated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "initialized": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["created", "updated"] + "accountId": { + "type": "string" }, - "sandboxes": { - "type": "array", - "items": { + "enterpriseUrl": { + "type": "string" + } + }, + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false + }, + "ApiAuth": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["api"] + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { "type": "string" } } }, - "required": ["id", "worktree", "time", "sandboxes"] + "required": ["type", "key"], + "additionalProperties": false }, - "Event.project.updated": { + "WellKnownAuth": { "type": "object", "properties": { "type": { "type": "string", - "const": "project.updated" + "enum": ["wellknown"] }, - "properties": { - "$ref": "#/components/schemas/Project" + "key": { + "type": "string" + }, + "token": { + "type": "string" } }, - "required": ["type", "properties"] + "required": ["type", "key", "token"], + "additionalProperties": false }, - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.instance.disposed" + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] - } - }, - "required": ["type", "properties"] - }, - "Event.server.connected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.connected" + { + "$ref": "#/components/schemas/ApiAuth" }, - "properties": { - "type": "object", - "properties": {} + { + "$ref": "#/components/schemas/WellKnownAuth" } - }, - "required": ["type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, - "Event.lsp.client.diagnostics": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "lsp.client.diagnostics" - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"] - } - }, - "required": ["type", "properties"] - }, - "Event.lsp.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "lsp.updated" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.message.part.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.delta" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "field": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["sessionID", "messageID", "partID", "field", "delta"] - } - }, - "required": ["type", "properties"] + ] }, "PermissionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^per.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "permission": { "type": "string" @@ -7854,11 +8818,7 @@ } }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "always": { "type": "array", @@ -7870,58 +8830,18 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] - } - }, - "required": ["type", "properties"] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false }, "SnapshotFileDiff": { "type": "object", @@ -7934,53 +8854,26 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] - }, - "Event.session.diff": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.diff" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["sessionID", "diff"] - } - }, - "required": ["type", "properties"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "ProviderAuthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ProviderAuthError" + "enum": ["ProviderAuthError"] }, "data": { "type": "object", @@ -7992,17 +8885,19 @@ "type": "string" } }, - "required": ["providerID", "message"] + "required": ["providerID", "message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "UnknownError": { "type": "object", "properties": { "name": { "type": "string", - "const": "UnknownError" + "enum": ["UnknownError"] }, "data": { "type": "object", @@ -8011,31 +8906,34 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageOutputLengthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageOutputLengthError" + "enum": ["MessageOutputLengthError"] }, "data": { "type": "object", "properties": {} } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageAbortedError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageAbortedError" + "enum": ["MessageAbortedError"] }, "data": { "type": "object", @@ -8044,17 +8942,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "StructuredOutputError": { "type": "object", "properties": { "name": { "type": "string", - "const": "StructuredOutputError" + "enum": ["StructuredOutputError"] }, "data": { "type": "object", @@ -8064,21 +8964,22 @@ }, "retries": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["message", "retries"] + "required": ["message", "retries"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "ContextOverflowError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ContextOverflowError" + "enum": ["ContextOverflowError"] }, "data": { "type": "object", @@ -8090,17 +8991,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "APIError": { "type": "object", "properties": { "name": { "type": "string", - "const": "APIError" + "enum": ["APIError"] }, "data": { "type": "object", @@ -8110,17 +9013,13 @@ }, "statusCode": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "isRetryable": { "type": "boolean" }, "responseHeaders": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -8130,193 +9029,96 @@ }, "metadata": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["message", "isRetryable"] + "required": ["message", "isRetryable"], + "additionalProperties": false } }, - "required": ["name", "data"] - }, - "Event.session.error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.error" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - } - } - } - }, - "required": ["type", "properties"] - }, - "Event.installation.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "installation.updated" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["type", "properties"] - }, - "Event.installation.update-available": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "installation.update-available" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["type", "properties"] + "required": ["name", "data"], + "additionalProperties": false }, "QuestionOption": { "type": "object", "properties": { "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "description": "Display text (1-5 words, concise)" }, "description": { - "description": "Explanation of choice", - "type": "string" + "type": "string", + "description": "Explanation of choice" } }, - "required": ["label", "description"] + "required": ["label", "description"], + "additionalProperties": false }, "QuestionInfo": { "type": "object", "properties": { "question": { - "description": "Complete question", - "type": "string" + "type": "string", + "description": "Complete question" }, "header": { - "description": "Very short label (max 30 chars)", - "type": "string" + "type": "string", + "description": "Very short label (max 30 chars)" }, "options": { - "description": "Available choices", "type": "array", "items": { "$ref": "#/components/schemas/QuestionOption" - } + }, + "description": "Available choices" }, "multiple": { - "description": "Allow selecting multiple choices", "type": "boolean" }, "custom": { - "description": "Allow typing a custom answer (default: true)", "type": "boolean" } }, - "required": ["question", "header", "options"] + "required": ["question", "header", "options"], + "additionalProperties": false }, "QuestionTool": { "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false }, "QuestionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "questions": { - "description": "Questions to ask", "type": "array", "items": { "$ref": "#/components/schemas/QuestionInfo" - } + }, + "description": "Questions to ask" }, "tool": { "$ref": "#/components/schemas/QuestionTool" } }, - "required": ["id", "sessionID", "questions"] - }, - "Event.question.asked": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["type", "properties"] + "required": ["id", "sessionID", "questions"], + "additionalProperties": false }, "QuestionAnswer": { "type": "array", @@ -8328,12 +9130,10 @@ "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "requestID": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "answers": { "type": "array", @@ -8342,91 +9142,40 @@ } } }, - "required": ["sessionID", "requestID", "answers"] - }, - "Event.question.replied": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "$ref": "#/components/schemas/QuestionReplied" - } - }, - "required": ["type", "properties"] + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false }, "QuestionRejected": { "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "requestID": { - "type": "string", - "pattern": "^que.*" + "type": "string" } }, - "required": ["sessionID", "requestID"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRejected" - } - }, - "required": ["type", "properties"] + "required": ["sessionID", "requestID"], + "additionalProperties": false }, "Todo": { "type": "object", "properties": { "content": { - "description": "Brief description of the task", - "type": "string" + "type": "string", + "description": "Brief description of the task" }, "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" }, "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] + "description": "Priority level of the task: high, medium, low" } }, - "required": ["type", "properties"] + "required": ["content", "status", "priority"], + "additionalProperties": false }, "SessionStatus": { "anyOf": [ @@ -8435,115 +9184,56 @@ "properties": { "type": { "type": "string", - "const": "idle" + "enum": ["idle"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "message": { "type": "string" }, "next": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "attempt", "message", "next"] + "required": ["type", "attempt", "message", "next"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "busy" + "enum": ["busy"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false } ] }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.compacted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.compacted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, "Event.tui.prompt.append": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.prompt.append" + "enum": ["tui.prompt.append"] }, "properties": { "type": "object", @@ -8552,17 +9242,22 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.command.execute": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.command.execute" + "enum": ["tui.command.execute"] }, "properties": { "type": "object", @@ -8596,17 +9291,22 @@ ] } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.toast.show": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.toast.show" + "enum": ["tui.toast.show"] }, "properties": { "type": "object", @@ -8622,270 +9322,117 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.session.select": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.session.select" + "enum": ["tui.session.select"] }, "properties": { "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Event.mcp.tools.changed": { + "Project": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "mcp.tools.changed" + "id": { + "type": "string" }, - "properties": { + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "enum": ["git"] + }, + "name": { + "type": "string" + }, + "icon": { "type": "object", "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, "url": { "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" } }, - "required": ["mcpName", "url"] - } - }, - "required": ["type", "properties"] - }, - "Event.command.executed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "command.executed" + "additionalProperties": false }, - "properties": { + "commands": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "sessionID": { + "start": { "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "description": "Startup script to run when creating a new workspace (worktree)" } }, - "required": ["name", "sessionID", "arguments", "messageID"] - } - }, - "required": ["type", "properties"] - }, - "Event.vcs.branch.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "vcs.branch.updated" + "additionalProperties": false }, - "properties": { + "time": { "type": "object", "properties": { - "branch": { - "type": "string" + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "initialized": { + "type": "integer", + "minimum": 0 } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" } } }, - "required": ["type", "properties"] - }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.worktree.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "worktree.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name", "branch"] - } - }, - "required": ["type", "properties"] - }, - "Event.worktree.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "worktree.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false }, "Pty": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "title": { "type": "string" @@ -8908,130 +9455,43 @@ }, "pid": { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"] - }, - "Event.pty.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.created" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.updated" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.exited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.exited" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "exitCode": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["id", "exitCode"] - } - }, - "required": ["type", "properties"] - }, - "Event.pty.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "pty.deleted" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - } - }, - "required": ["id"] - } - }, - "required": ["type", "properties"] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "additionalProperties": false }, "OutputFormatText": { "type": "object", "properties": { "type": { "type": "string", - "const": "text" + "enum": ["text"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, "JSONSchema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "OutputFormatJsonSchema": { "type": "object", "properties": { "type": { "type": "string", - "const": "json_schema" + "enum": ["json_schema"] }, "schema": { "$ref": "#/components/schemas/JSONSchema" }, "retryCount": { - "default": 2, "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "schema"] + "required": ["type", "schema"], + "additionalProperties": false }, "OutputFormat": { "anyOf": [ @@ -9047,27 +9507,25 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "user" + "enum": ["user"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -9088,7 +9546,8 @@ } } }, - "required": ["diffs"] + "required": ["diffs"], + "additionalProperties": false }, "agent": { "type": "string" @@ -9106,53 +9565,49 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "system": { "type": "string" }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"] + "required": ["id", "sessionID", "role", "time", "agent", "model"], + "additionalProperties": false }, "AssistantMessage": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "assistant" + "enum": ["assistant"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "completed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "error": { "anyOf": [ @@ -9180,8 +9635,7 @@ ] }, "parentID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "modelID": { "type": "string" @@ -9205,7 +9659,8 @@ "type": "string" } }, - "required": ["cwd", "root"] + "required": ["cwd", "root"], + "additionalProperties": false }, "summary": { "type": "boolean" @@ -9218,42 +9673,38 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false }, "structured": {}, "variant": { @@ -9276,7 +9727,8 @@ "path", "cost", "tokens" - ] + ], + "additionalProperties": false }, "Message": { "anyOf": [ @@ -9288,71 +9740,21 @@ } ] }, - "Event.message.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "Event.message.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["type", "properties"] - }, "TextPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "text" + "enum": ["text"] }, "text": { "type": "string" @@ -9368,45 +9770,38 @@ "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "text"] + "required": ["id", "sessionID", "messageID", "type", "text"], + "additionalProperties": false }, "SubtaskPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "subtask" + "enum": ["subtask"] }, "prompt": { "type": "string" @@ -9427,61 +9822,56 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "command": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], + "additionalProperties": false }, "ReasoningPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "reasoning" + "enum": ["reasoning"] }, "text": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "text", "time"] + "required": ["id", "sessionID", "messageID", "type", "text", "time"], + "additionalProperties": false }, "FilePartSourceText": { "type": "object", @@ -9491,16 +9881,15 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false }, "FileSource": { "type": "object", @@ -9510,13 +9899,14 @@ }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "path": { "type": "string" } }, - "required": ["text", "type", "path"] + "required": ["text", "type", "path"], + "additionalProperties": false }, "Range": { "type": "object", @@ -9526,35 +9916,34 @@ "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false }, "end": { "type": "object", "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "SymbolSource": { "type": "object", @@ -9564,7 +9953,7 @@ }, "type": { "type": "string", - "const": "symbol" + "enum": ["symbol"] }, "path": { "type": "string" @@ -9577,11 +9966,11 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["text", "type", "path", "range", "name", "kind"] + "required": ["text", "type", "path", "range", "name", "kind"], + "additionalProperties": false }, "ResourceSource": { "type": "object", @@ -9591,7 +9980,7 @@ }, "type": { "type": "string", - "const": "resource" + "enum": ["resource"] }, "clientName": { "type": "string" @@ -9600,7 +9989,8 @@ "type": "string" } }, - "required": ["text", "type", "clientName", "uri"] + "required": ["text", "type", "clientName", "uri"], + "additionalProperties": false }, "FilePartSource": { "anyOf": [ @@ -9619,20 +10009,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "mime": { "type": "string" @@ -9647,79 +10034,66 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["id", "sessionID", "messageID", "type", "mime", "url"] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "additionalProperties": false }, "ToolStatePending": { "type": "object", "properties": { "status": { "type": "string", - "const": "pending" + "enum": ["pending"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "raw": { "type": "string" } }, - "required": ["status", "input", "raw"] + "required": ["status", "input", "raw"], + "additionalProperties": false }, "ToolStateRunning": { "type": "object", "properties": { "status": { "type": "string", - "const": "running" + "enum": ["running"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "title": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["status", "input", "time"] + "required": ["status", "input", "time"], + "additionalProperties": false }, "ToolStateCompleted": { "type": "object", "properties": { "status": { "type": "string", - "const": "completed" + "enum": ["completed"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "output": { "type": "string" @@ -9728,32 +10102,26 @@ "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacted": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "attachments": { "type": "array", @@ -9762,50 +10130,43 @@ } } }, - "required": ["status", "input", "output", "title", "metadata", "time"] + "required": ["status", "input", "output", "title", "metadata", "time"], + "additionalProperties": false }, "ToolStateError": { "type": "object", "properties": { "status": { "type": "string", - "const": "error" + "enum": ["error"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "error": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false } }, - "required": ["status", "input", "error", "time"] + "required": ["status", "input", "error", "time"], + "additionalProperties": false }, "ToolState": { "anyOf": [ @@ -9827,20 +10188,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "tool" + "enum": ["tool"] }, "callID": { "type": "string" @@ -9852,58 +10210,50 @@ "$ref": "#/components/schemas/ToolState" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], + "additionalProperties": false }, "StepStartPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-start" + "enum": ["step-start"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"] + "required": ["id", "sessionID", "messageID", "type"], + "additionalProperties": false }, "StepFinishPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-finish" + "enum": ["step-finish"] }, "reason": { "type": "string" @@ -9919,89 +10269,81 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], + "additionalProperties": false }, "SnapshotPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "snapshot" + "enum": ["snapshot"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "snapshot"] + "required": ["id", "sessionID", "messageID", "type", "snapshot"], + "additionalProperties": false }, "PatchPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "patch" + "enum": ["patch"] }, "hash": { "type": "string" @@ -10013,26 +10355,24 @@ } } }, - "required": ["id", "sessionID", "messageID", "type", "hash", "files"] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"], + "additionalProperties": false }, "AgentPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "agent" + "enum": ["agent"] }, "name": { "type": "string" @@ -10045,43 +10385,39 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "name"] + "required": ["id", "sessionID", "messageID", "type", "name"], + "additionalProperties": false }, "RetryPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "error": { "$ref": "#/components/schemas/APIError" @@ -10091,33 +10427,31 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], + "additionalProperties": false }, "CompactionPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "compaction" + "enum": ["compaction"] }, "auto": { "type": "boolean" @@ -10126,11 +10460,11 @@ "type": "boolean" }, "tail_start_id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "auto"] + "required": ["id", "sessionID", "messageID", "type", "auto"], + "additionalProperties": false }, "Part": { "anyOf": [ @@ -10172,62 +10506,6 @@ } ] }, - "Event.message.part.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["type", "properties"] - }, - "Event.message.part.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "message.part.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["type", "properties"] - }, "PermissionAction": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -10245,7 +10523,8 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": ["permission", "pattern", "action"] + "required": ["permission", "pattern", "action"], + "additionalProperties": false }, "PermissionRuleset": { "type": "array", @@ -10257,8 +10536,7 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -10267,8 +10545,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -10277,26 +10554,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -10305,7 +10578,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -10314,11 +10588,31 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "version": { "type": "string" }, @@ -10327,24 +10621,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -10353,12 +10645,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -10367,581 +10657,34 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false }, - "Event.session.created": { + "Prompt": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "session.created" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.deleted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "properties"] - }, - "SyncEvent.message.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.updated.1" - }, - "id": { + "text": { "type": "string" }, - "seq": { - "type": "number" + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.removed.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.removed.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.created.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "projectID": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "workspaceID": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk.*" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "parentID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"] - }, - { - "type": "null" - } - ] - }, - "share": { - "type": "object", - "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "updated": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "compacting": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "archived": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - } - }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] - }, - "revert": { - "anyOf": [ - { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"] - }, - { - "type": "null" - } - ] - } - } - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.deleted.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["text"], + "additionalProperties": false }, "GlobalEvent": { "type": "object", @@ -10958,70 +10701,61 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/EventServerInstanceDisposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/EventFileEdited" }, { - "$ref": "#/components/schemas/Event.server.connected" + "$ref": "#/components/schemas/EventFileWatcherUpdated" }, { - "$ref": "#/components/schemas/Event.global.disposed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { - "$ref": "#/components/schemas/Event.file.edited" + "$ref": "#/components/schemas/EventLspUpdated" }, { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/EventMessagePartDelta" }, { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" + "$ref": "#/components/schemas/EventPermissionAsked" }, { - "$ref": "#/components/schemas/Event.lsp.updated" + "$ref": "#/components/schemas/EventPermissionReplied" }, { - "$ref": "#/components/schemas/Event.message.part.delta" + "$ref": "#/components/schemas/EventSessionDiff" }, { - "$ref": "#/components/schemas/Event.permission.asked" + "$ref": "#/components/schemas/EventSessionError" }, { - "$ref": "#/components/schemas/Event.permission.replied" + "$ref": "#/components/schemas/EventInstallationUpdated" }, { - "$ref": "#/components/schemas/Event.session.diff" + "$ref": "#/components/schemas/EventInstallationUpdate-available" }, { - "$ref": "#/components/schemas/Event.session.error" + "$ref": "#/components/schemas/EventQuestionAsked" }, { - "$ref": "#/components/schemas/Event.installation.updated" + "$ref": "#/components/schemas/EventQuestionReplied" }, { - "$ref": "#/components/schemas/Event.installation.update-available" + "$ref": "#/components/schemas/EventQuestionRejected" }, { - "$ref": "#/components/schemas/Event.question.asked" + "$ref": "#/components/schemas/EventTodoUpdated" }, { - "$ref": "#/components/schemas/Event.question.replied" + "$ref": "#/components/schemas/EventSessionStatus" }, { - "$ref": "#/components/schemas/Event.question.rejected" + "$ref": "#/components/schemas/EventSessionIdle" }, { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/EventSessionCompacted" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -11036,129 +10770,287 @@ "$ref": "#/components/schemas/Event.tui.session.select" }, { - "$ref": "#/components/schemas/Event.mcp.tools.changed" + "$ref": "#/components/schemas/EventMcpToolsChanged" }, { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, { - "$ref": "#/components/schemas/Event.command.executed" + "$ref": "#/components/schemas/EventCommandExecuted" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/EventWorkspaceReady" }, { - "$ref": "#/components/schemas/Event.workspace.restore" + "$ref": "#/components/schemas/EventWorkspaceFailed" }, { - "$ref": "#/components/schemas/Event.workspace.status" + "$ref": "#/components/schemas/EventWorkspaceStatus" }, { - "$ref": "#/components/schemas/Event.worktree.ready" + "$ref": "#/components/schemas/EventWorktreeReady" }, { - "$ref": "#/components/schemas/Event.worktree.failed" + "$ref": "#/components/schemas/EventWorktreeFailed" }, { - "$ref": "#/components/schemas/Event.pty.created" + "$ref": "#/components/schemas/EventPtyCreated" }, { - "$ref": "#/components/schemas/Event.pty.updated" + "$ref": "#/components/schemas/EventPtyUpdated" }, { - "$ref": "#/components/schemas/Event.pty.exited" + "$ref": "#/components/schemas/EventPtyExited" }, { - "$ref": "#/components/schemas/Event.pty.deleted" + "$ref": "#/components/schemas/EventPtyDeleted" }, { - "$ref": "#/components/schemas/Event.message.updated" + "$ref": "#/components/schemas/EventMessageUpdated" }, { - "$ref": "#/components/schemas/Event.message.removed" + "$ref": "#/components/schemas/EventMessageRemoved" }, { - "$ref": "#/components/schemas/Event.message.part.updated" + "$ref": "#/components/schemas/EventMessagePartUpdated" }, { - "$ref": "#/components/schemas/Event.message.part.removed" + "$ref": "#/components/schemas/EventMessagePartRemoved" }, { - "$ref": "#/components/schemas/Event.session.created" + "$ref": "#/components/schemas/EventSessionCreated" }, { - "$ref": "#/components/schemas/Event.session.updated" + "$ref": "#/components/schemas/EventSessionUpdated" }, { - "$ref": "#/components/schemas/Event.session.deleted" + "$ref": "#/components/schemas/EventSessionDeleted" }, { - "$ref": "#/components/schemas/SyncEvent.message.updated" + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, { - "$ref": "#/components/schemas/SyncEvent.message.removed" + "$ref": "#/components/schemas/EventSessionNextModelSwitched" }, { - "$ref": "#/components/schemas/SyncEvent.message.part.updated" + "$ref": "#/components/schemas/EventSessionNextPrompted" }, { - "$ref": "#/components/schemas/SyncEvent.message.part.removed" + "$ref": "#/components/schemas/EventSessionNextSynthetic" }, { - "$ref": "#/components/schemas/SyncEvent.session.created" + "$ref": "#/components/schemas/EventSessionNextShellStarted" }, { - "$ref": "#/components/schemas/SyncEvent.session.updated" + "$ref": "#/components/schemas/EventSessionNextShellEnded" }, { - "$ref": "#/components/schemas/SyncEvent.session.deleted" + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/SyncEventMessageUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessageRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventSessionCreated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionDeleted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" } ] } }, - "required": ["directory", "payload"] + "required": ["directory", "payload"], + "additionalProperties": false }, "LogLevel": { - "description": "Log level", "type": "string", - "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + "enum": ["DEBUG", "INFO", "WARN", "ERROR"], + "description": "Log level" }, "ServerConfig": { - "description": "Server configuration for opencode serve and web commands", "type": "object", "properties": { "port": { - "description": "Port to listen on", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "hostname": { - "description": "Hostname to listen on", "type": "string" }, "mdns": { - "description": "Enable mDNS service discovery", "type": "boolean" }, "mdnsDomain": { - "description": "Custom domain name for mDNS service (default: opencode.local)", "type": "string" }, "cors": { - "description": "Additional domains to allow for CORS", "type": "array", "items": { "type": "string" } } - } + }, + "additionalProperties": false, + "description": "Server configuration for opencode serve and web commands" }, "PermissionActionConfig": { "type": "string", @@ -11166,9 +11058,6 @@ }, "PermissionObjectConfig": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/PermissionActionConfig" } @@ -11250,7 +11139,6 @@ "type": "string" }, "variant": { - "description": "Default model variant for this agent (applies only when using the agent's configured model).", "type": "string" }, "temperature": { @@ -11263,11 +11151,7 @@ "type": "string" }, "tools": { - "description": "@deprecated Use 'permission' field instead", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -11276,7 +11160,6 @@ "type": "boolean" }, "description": { - "description": "Description of when to use the agent", "type": "string" }, "mode": { @@ -11284,18 +11167,12 @@ "enum": ["subagent", "primary", "all"] }, "hidden": { - "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", "type": "boolean" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "color": { - "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", "anyOf": [ { "type": "string", @@ -11305,19 +11182,16 @@ "type": "string", "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } - ] + ], + "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)" }, "steps": { - "description": "Maximum number of agentic iterations before forcing text-only response", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "maxSteps": { - "description": "@deprecated Use 'steps' field instead.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "permission": { "$ref": "#/components/schemas/PermissionConfig" @@ -11368,41 +11242,33 @@ "type": "string" }, "enterpriseUrl": { - "description": "GitHub Enterprise URL for copilot authentication", "type": "string" }, "setCacheKey": { - "description": "Enable promptCacheKey for this provider (default false)", "type": "boolean" }, "timeout": { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, { "type": "boolean", - "const": false + "enum": [false] } - ] + ], + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout." }, "chunkTimeout": { - "description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, "additionalProperties": {} }, "models": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -11434,7 +11300,7 @@ "anyOf": [ { "type": "boolean", - "const": true + "enum": [true] }, { "type": "object", @@ -11444,7 +11310,8 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"] + "required": ["field"], + "additionalProperties": false } ] }, @@ -11479,10 +11346,12 @@ "type": "number" } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false }, "limit": { "type": "object", @@ -11497,7 +11366,8 @@ "type": "number" } }, - "required": ["context", "output"] + "required": ["context", "output"], + "additionalProperties": false }, "modalities": { "type": "object", @@ -11517,7 +11387,8 @@ } } }, - "required": ["input", "output"] + "required": ["input", "output"], + "additionalProperties": false }, "experimental": { "type": "boolean" @@ -11535,166 +11406,141 @@ "api": { "type": "string" } - } + }, + "additionalProperties": false }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "variants": { - "description": "Variant-specific configuration", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { "disabled": { - "description": "Disable this variant for the model", "type": "boolean" } }, "additionalProperties": {} - } + }, + "description": "Variant-specific configuration" } - } + }, + "additionalProperties": false } } - } + }, + "additionalProperties": false }, "McpLocalConfig": { "type": "object", "properties": { "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "local" + "enum": ["local"], + "description": "Type of MCP server connection" }, "command": { - "description": "Command and arguments to run the MCP server", "type": "array", "items": { "type": "string" - } + }, + "description": "Command and arguments to run the MCP server" }, "environment": { - "description": "Environment variables to set when running the MCP server", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "enabled": { - "description": "Enable or disable the MCP server on startup", "type": "boolean" }, "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["type", "command"] + "required": ["type", "command"], + "additionalProperties": false }, "McpOAuthConfig": { "type": "object", "properties": { "clientId": { - "description": "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", "type": "string" }, "clientSecret": { - "description": "OAuth client secret (if required by the authorization server)", "type": "string" }, "scope": { - "description": "OAuth scopes to request during authorization", "type": "string" }, "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "type": "string" } - } + }, + "additionalProperties": false }, "McpRemoteConfig": { "type": "object", "properties": { "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "remote" + "enum": ["remote"], + "description": "Type of MCP server connection" }, "url": { - "description": "URL of the remote MCP server", - "type": "string" + "type": "string", + "description": "URL of the remote MCP server" }, "enabled": { - "description": "Enable or disable the MCP server on startup", "type": "boolean" }, "headers": { - "description": "Headers to send with the request", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "oauth": { - "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", "anyOf": [ { "$ref": "#/components/schemas/McpOAuthConfig" }, { "type": "boolean", - "const": false + "enum": [false] } - ] + ], + "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection." }, "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["type", "url"] + "required": ["type", "url"], + "additionalProperties": false }, "LayoutConfig": { - "description": "@deprecated Always uses stretch layout.", "type": "string", - "enum": ["auto", "stretch"] + "enum": ["auto", "stretch"], + "description": "@deprecated Always uses stretch layout." }, "Config": { "type": "object", "properties": { "$schema": { - "description": "JSON schema reference for configuration validation", "type": "string" }, "shell": { - "description": "Default shell to use for terminal and bash tool", "type": "string" }, "logLevel": { @@ -11704,11 +11550,7 @@ "$ref": "#/components/schemas/ServerConfig" }, "command": { - "description": "Command configuration, see https://opencode.ai/docs/commands", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -11728,28 +11570,27 @@ "type": "boolean" } }, - "required": ["template"] + "required": ["template"], + "additionalProperties": false } }, "skills": { - "description": "Additional skill folder paths", "type": "object", "properties": { "paths": { - "description": "Additional paths to skill folders", "type": "array", "items": { "type": "string" } }, "urls": { - "description": "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", "type": "array", "items": { "type": "string" } } - } + }, + "additionalProperties": false }, "watcher": { "type": "object", @@ -11760,10 +11601,10 @@ "type": "string" } } - } + }, + "additionalProperties": false }, "snapshot": { - "description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", "type": "boolean" }, "plugin": { @@ -11780,70 +11621,59 @@ "type": "string" }, { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } - ] + ], + "maxItems": 2, + "minItems": 2 } ] } }, "share": { - "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", "type": "string", "enum": ["manual", "auto", "disabled"] }, "autoshare": { - "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", "type": "boolean" }, "autoupdate": { - "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", "anyOf": [ { "type": "boolean" }, { "type": "string", - "const": "notify" + "enum": ["notify"] } - ] + ], + "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications" }, "disabled_providers": { - "description": "Disable providers that are loaded automatically", "type": "array", "items": { "type": "string" } }, "enabled_providers": { - "description": "When set, ONLY these providers will be enabled. All other providers will be ignored", "type": "array", "items": { "type": "string" } }, "model": { - "description": "Model to use in the format of provider/model, eg anthropic/claude-2", "type": "string" }, "small_model": { - "description": "Small model to use for tasks like title generation in the format of provider/model", "type": "string" }, "default_agent": { - "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", "type": "string" }, "username": { - "description": "Custom username to display in conversations instead of system username", "type": "string" }, "mode": { - "description": "@deprecated Use `agent` field instead.", "type": "object", "properties": { "build": { @@ -11858,7 +11688,6 @@ } }, "agent": { - "description": "Agent configuration, see https://opencode.ai/docs/agents", "type": "object", "properties": { "plan": { @@ -11888,32 +11717,20 @@ } }, "provider": { - "description": "Custom provider configurations and model overrides", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/ProviderConfig" } }, "mcp": { - "description": "MCP (Model Context Protocol) server configurations", "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "anyOf": [ { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" }, { "type": "object", @@ -11922,7 +11739,8 @@ "type": "boolean" } }, - "required": ["enabled"] + "required": ["enabled"], + "additionalProperties": false } ] } @@ -11934,9 +11752,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "object", "properties": { @@ -11951,9 +11766,6 @@ }, "environment": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -11964,10 +11776,12 @@ "type": "string" } } - } + }, + "additionalProperties": false } } - ] + ], + "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, "lsp": { "anyOf": [ @@ -11976,9 +11790,6 @@ }, { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "anyOf": [ { @@ -11986,10 +11797,11 @@ "properties": { "disabled": { "type": "boolean", - "const": true + "enum": [true] } }, - "required": ["disabled"] + "required": ["disabled"], + "additionalProperties": false }, { "type": "object", @@ -12011,30 +11823,24 @@ }, "env": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } }, "initialization": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } ] } } - ] + ], + "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, "instructions": { - "description": "Additional instruction files or patterns to include", "type": "array", "items": { "type": "string" @@ -12048,9 +11854,6 @@ }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } @@ -12059,59 +11862,48 @@ "type": "object", "properties": { "url": { - "description": "Enterprise URL", "type": "string" } - } + }, + "additionalProperties": false }, "tool_output": { - "description": "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", "type": "object", "properties": { "max_lines": { - "description": "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 }, "max_bytes": { - "description": "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } - } + }, + "additionalProperties": false }, "compaction": { "type": "object", "properties": { "auto": { - "description": "Enable automatic compaction when context is full (default: true)", "type": "boolean" }, "prune": { - "description": "Enable pruning of old tool outputs (default: true)", "type": "boolean" }, "tail_turns": { - "description": "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "preserve_recent_tokens": { - "description": "Maximum number of tokens from recent turns to preserve verbatim after compaction", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reserved": { - "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } - } + }, + "additionalProperties": false }, "experimental": { "type": "object", @@ -12120,200 +11912,30 @@ "type": "boolean" }, "batch_tool": { - "description": "Enable the batch tool", "type": "boolean" }, "openTelemetry": { - "description": "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", "type": "boolean" }, "primary_tools": { - "description": "Tools that should only be available to primary agents.", "type": "array", "items": { "type": "string" } }, "continue_loop_on_deny": { - "description": "Continue the agent loop when a tool call is denied", "type": "boolean" }, "mcp_timeout": { - "description": "Timeout in milliseconds for model context protocol (MCP) requests", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } - } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "BadRequestError": { - "type": "object", - "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "success": { - "type": "boolean", - "const": false - } - }, - "required": ["data", "errors", "success"] - }, - "OAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "oauth" - }, - "refresh": { - "type": "string" - }, - "access": { - "type": "string" - }, - "expires": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "accountId": { - "type": "string" - }, - "enterpriseUrl": { - "type": "string" - } - }, - "required": ["type", "refresh", "access", "expires"] - }, - "ApiAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "api" - }, - "key": { - "type": "string" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["type", "key"] - }, - "WellKnownAuth": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "wellknown" - }, - "key": { - "type": "string" - }, - "token": { - "type": "string" - } - }, - "required": ["type", "key", "token"] - }, - "Auth": { - "anyOf": [ - { - "$ref": "#/components/schemas/OAuth" - }, - { - "$ref": "#/components/schemas/ApiAuth" - }, - { - "$ref": "#/components/schemas/WellKnownAuth" - } - ] - }, - "Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "projectID": { - "type": "string" - } - }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] - }, - "NotFoundError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "NotFoundError" - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["name", "data"] - }, "Model": { "type": "object", "properties": { @@ -12336,7 +11958,8 @@ "type": "string" } }, - "required": ["id", "url", "npm"] + "required": ["id", "url", "npm"], + "additionalProperties": false }, "name": { "type": "string" @@ -12378,7 +12001,8 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false }, "output": { "type": "object", @@ -12399,7 +12023,8 @@ "type": "boolean" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false }, "interleaved": { "anyOf": [ @@ -12414,12 +12039,14 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"] + "required": ["field"], + "additionalProperties": false } ] } }, - "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"], + "additionalProperties": false }, "cost": { "type": "object", @@ -12440,7 +12067,8 @@ "type": "number" } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false }, "experimentalOver200K": { "type": "object", @@ -12461,13 +12089,16 @@ "type": "number" } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "cache"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "cache"], + "additionalProperties": false }, "limit": { "type": "object", @@ -12482,24 +12113,18 @@ "type": "number" } }, - "required": ["context", "output"] + "required": ["context", "output"], + "additionalProperties": false }, "status": { "type": "string", "enum": ["alpha", "beta", "deprecated", "active"] }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "headers": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -12509,15 +12134,8 @@ }, "variants": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } } }, @@ -12533,7 +12151,8 @@ "options", "headers", "release_date" - ] + ], + "additionalProperties": false }, "Provider": { "type": "object", @@ -12558,23 +12177,17 @@ "type": "string" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "models": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "$ref": "#/components/schemas/Model" } } }, - "required": ["id", "name", "source", "env", "options", "models"] + "required": ["id", "name", "source", "env", "options", "models"], + "additionalProperties": false }, "ConsoleState": { "type": "object", @@ -12590,17 +12203,11 @@ }, "switchableOrgCount": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["consoleManagedProviders", "switchableOrgCount"] - }, - "ToolIDs": { - "type": "array", - "items": { - "type": "string" - } + "required": ["consoleManagedProviders", "switchableOrgCount"], + "additionalProperties": false }, "ToolListItem": { "type": "object", @@ -12613,7 +12220,8 @@ }, "parameters": {} }, - "required": ["id", "description", "parameters"] + "required": ["id", "description", "parameters"], + "additionalProperties": false }, "ToolList": { "type": "array", @@ -12621,6 +12229,25 @@ "$ref": "#/components/schemas/ToolListItem" } }, + "ToolIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "WorktreeCreateInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "startCommand": { + "type": "string", + "description": "Additional startup script to run after the project's start command" + } + }, + "additionalProperties": false + }, "Worktree": { "type": "object", "properties": { @@ -12634,19 +12261,8 @@ "type": "string" } }, - "required": ["name", "branch", "directory"] - }, - "WorktreeCreateInput": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "startCommand": { - "description": "Additional startup script to run after the project's start command", - "type": "string" - } - } + "required": ["name", "branch", "directory"], + "additionalProperties": false }, "WorktreeRemoveInput": { "type": "object", @@ -12655,7 +12271,8 @@ "type": "string" } }, - "required": ["directory"] + "required": ["directory"], + "additionalProperties": false }, "WorktreeResetInput": { "type": "object", @@ -12664,7 +12281,8 @@ "type": "string" } }, - "required": ["directory"] + "required": ["directory"], + "additionalProperties": false }, "ProjectSummary": { "type": "object", @@ -12679,14 +12297,14 @@ "type": "string" } }, - "required": ["id", "worktree"] + "required": ["id", "worktree"], + "additionalProperties": false }, "GlobalSession": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -12695,8 +12313,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -12705,26 +12322,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -12733,7 +12346,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -12742,11 +12356,31 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, "version": { "type": "string" }, @@ -12755,24 +12389,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -12781,12 +12413,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -12795,7 +12425,8 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false }, "project": { "anyOf": [ @@ -12808,7 +12439,8 @@ ] } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"], + "additionalProperties": false }, "McpResource": { "type": "object", @@ -12829,274 +12461,8 @@ "type": "string" } }, - "required": ["name", "uri", "client"] - }, - "TextPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "text" - }, - "text": { - "type": "string" - }, - "synthetic": { - "type": "boolean" - }, - "ignored": { - "type": "boolean" - }, - "time": { - "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["start"] - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["type", "text"] - }, - "FilePartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - }, - "AgentPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "agent" - }, - "name": { - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["value", "start", "end"] - } - }, - "required": ["type", "name"] - }, - "SubtaskPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "subtask" - }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["type", "prompt", "description", "agent"] - }, - "ProviderAuthMethod": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["oauth", "api"] - }, - "label": { - "type": "string" - }, - "prompts": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "placeholder": { - "type": "string" - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "select" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "options": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "hint": { - "type": "string" - } - }, - "required": ["label", "value"] - } - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message", "options"] - } - ] - } - } - }, - "required": ["type", "label"] - }, - "ProviderAuthAuthorization": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "method": { - "type": "string", - "enum": ["auto", "code"] - }, - "instructions": { - "type": "string" - } - }, - "required": ["url", "method", "instructions"] + "required": ["name", "uri", "client"], + "additionalProperties": false }, "Symbol": { "type": "object", @@ -13106,8 +12472,7 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "location": { "type": "object", @@ -13119,10 +12484,12 @@ "$ref": "#/components/schemas/Range" } }, - "required": ["uri", "range"] + "required": ["uri", "range"], + "additionalProperties": false } }, - "required": ["name", "kind", "location"] + "required": ["name", "kind", "location"], + "additionalProperties": false }, "FileNode": { "type": "object", @@ -13144,7 +12511,8 @@ "type": "boolean" } }, - "required": ["name", "path", "absolute", "type", "ignored"] + "required": ["name", "path", "absolute", "type", "ignored"], + "additionalProperties": false }, "FileContent": { "type": "object", @@ -13181,23 +12549,19 @@ "properties": { "oldStart": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "oldLines": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "newStart": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "newLines": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "lines": { "type": "array", @@ -13206,24 +12570,27 @@ } } }, - "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"], + "additionalProperties": false } }, "index": { "type": "string" } }, - "required": ["oldFileName", "newFileName", "hunks"] + "required": ["oldFileName", "newFileName", "hunks"], + "additionalProperties": false }, "encoding": { "type": "string", - "const": "base64" + "enum": ["base64"] }, "mimeType": { "type": "string" } }, - "required": ["type", "content"] + "required": ["type", "content"], + "additionalProperties": false }, "File": { "type": "object", @@ -13233,249 +12600,19 @@ }, "added": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "removed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["path", "added", "removed", "status"] - }, - "Event": { - "anyOf": [ - { - "$ref": "#/components/schemas/Event.project.updated" - }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" - }, - { - "$ref": "#/components/schemas/Event.lsp.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.delta" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.session.diff" - }, - { - "$ref": "#/components/schemas/Event.session.error" - }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/Event.mcp.tools.changed" - }, - { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" - }, - { - "$ref": "#/components/schemas/Event.command.executed" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.worktree.ready" - }, - { - "$ref": "#/components/schemas/Event.worktree.failed" - }, - { - "$ref": "#/components/schemas/Event.pty.created" - }, - { - "$ref": "#/components/schemas/Event.pty.updated" - }, - { - "$ref": "#/components/schemas/Event.pty.exited" - }, - { - "$ref": "#/components/schemas/Event.pty.deleted" - }, - { - "$ref": "#/components/schemas/Event.message.updated" - }, - { - "$ref": "#/components/schemas/Event.message.removed" - }, - { - "$ref": "#/components/schemas/Event.message.part.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.removed" - }, - { - "$ref": "#/components/schemas/Event.session.created" - }, - { - "$ref": "#/components/schemas/Event.session.updated" - }, - { - "$ref": "#/components/schemas/Event.session.deleted" - } - ] - }, - "MCPStatusConnected": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "connected" - } - }, - "required": ["status"] - }, - "MCPStatusDisabled": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "disabled" - } - }, - "required": ["status"] - }, - "MCPStatusFailed": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "failed" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatusNeedsAuth": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "needs_auth" - } - }, - "required": ["status"] - }, - "MCPStatusNeedsClientRegistration": { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "needs_client_registration" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatus": { - "anyOf": [ - { - "$ref": "#/components/schemas/MCPStatusConnected" - }, - { - "$ref": "#/components/schemas/MCPStatusDisabled" - }, - { - "$ref": "#/components/schemas/MCPStatusFailed" - }, - { - "$ref": "#/components/schemas/MCPStatusNeedsAuth" - }, - { - "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" - } - ] - }, - "McpUnsupportedOAuthError": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - }, - "required": ["error"] + "required": ["path", "added", "removed", "status"], + "additionalProperties": false }, "Path": { "type": "object", @@ -13496,7 +12633,8 @@ "type": "string" } }, - "required": ["home", "state", "config", "worktree", "directory"] + "required": ["home", "state", "config", "worktree", "directory"], + "additionalProperties": false }, "VcsInfo": { "type": "object", @@ -13507,7 +12645,8 @@ "default_branch": { "type": "string" } - } + }, + "additionalProperties": false }, "VcsFileDiff": { "type": "object", @@ -13520,20 +12659,19 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "Command": { "type": "object", @@ -13555,14 +12693,7 @@ "enum": ["command", "mcp", "skill"] }, "template": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "string" - } - ] + "type": "string" }, "subtask": { "type": "boolean" @@ -13574,7 +12705,8 @@ } } }, - "required": ["name", "template", "hints"] + "required": ["name", "template", "hints"], + "additionalProperties": false }, "Agent": { "type": "object", @@ -13617,7 +12749,8 @@ "type": "string" } }, - "required": ["modelID", "providerID"] + "required": ["modelID", "providerID"], + "additionalProperties": false }, "variant": { "type": "string" @@ -13626,17 +12759,14 @@ "type": "string" }, "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "steps": { "type": "number" } }, - "required": ["name", "mode", "permission", "options"] + "required": ["name", "mode", "permission", "options"], + "additionalProperties": false }, "LSPStatus": { "type": "object", @@ -13651,19 +12781,12 @@ "type": "string" }, "status": { - "anyOf": [ - { - "type": "string", - "const": "connected" - }, - { - "type": "string", - "const": "error" - } - ] + "type": "string", + "enum": ["connected", "error"] } }, - "required": ["id", "name", "root", "status"] + "required": ["id", "name", "root", "status"], + "additionalProperties": false }, "FormatterStatus": { "type": "object", @@ -13681,8 +12804,5324 @@ "type": "boolean" } }, - "required": ["name", "extensions", "enabled"] + "required": ["name", "extensions", "enabled"], + "additionalProperties": false + }, + "MCPStatusConnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["connected"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusDisabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["disabled"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusFailed": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["failed"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatusNeedsAuth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_auth"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusNeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_client_registration"] + }, + "error": { + "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPStatusConnected" + }, + { + "$ref": "#/components/schemas/MCPStatusDisabled" + }, + { + "$ref": "#/components/schemas/MCPStatusFailed" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsAuth" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" + } + ] + }, + "McpUnsupportedOAuthError": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"], + "additionalProperties": false + }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth", "api"] + }, + "label": { + "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["select"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message", "options"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["type", "label"], + "additionalProperties": false + }, + "ProviderAuthAuthorization": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "type": "string", + "enum": ["auto", "code"] + }, + "instructions": { + "type": "string" + } + }, + "required": ["url", "method", "instructions"], + "additionalProperties": false + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start"], + "additionalProperties": false + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agent"] + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + "SubtaskPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["subtask"] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["type", "prompt", "description", "agent"], + "additionalProperties": false + }, + "V2SessionsResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "V2SessionMessagesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "EventTuiPromptAppend": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiCommandExecute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiToastShow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiSessionSelect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + } + }, + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "additionalProperties": false + }, + "SyncEventMessageUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessageRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.created.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "share": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "updated": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "compacting": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "archived": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "permission": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionRuleset" + }, + { + "type": "null" + } + ] + }, + "revert": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.deleted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.agent.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextModelSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.model.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextPrompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.prompted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextSynthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.synthetic.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolCalled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.called.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolSuccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.success.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextRetried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.retried.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "field": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "requestID": { + "type": "string" + }, + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionError": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.update-available"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionReplied" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRejected" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.idle"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.compacted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpBrowserOpenFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorkspaceStatus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "branch": { + "type": "string" + } + }, + "required": ["name", "branch"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.created"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.updated"] + }, + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "exitCode": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.deleted"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.model.switched"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "PromptSource": { + "type": "object", + "properties": { + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"], + "additionalProperties": false + }, + "PromptFileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri", "mime"], + "additionalProperties": false + }, + "PromptAgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name"], + "additionalProperties": false + }, + "EventSessionNextPrompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.prompted"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextShellEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionErrorUnknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolCalled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "ToolTextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "ToolFileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["file"] + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"], + "additionalProperties": false + }, + "EventSessionNextToolProgress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.progress"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.success"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.tool.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionNextRetry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"], + "additionalProperties": false + }, + "EventSessionNextRetried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.retried"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "attempt": { + "type": "integer", + "minimum": 0 + }, + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.delta"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.compaction.ended"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "SessionInfo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "parentID": { + "type": "string" + }, + "projectID": { + "type": "string" + }, + "workspaceID": { + "type": "string" + }, + "path": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "updated": { + "type": "number" + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false + }, + "title": { + "type": "string" + } + }, + "required": ["id", "projectID", "time", "title"], + "additionalProperties": false + }, + "SessionDelivery": { + "type": "string", + "enum": ["immediate", "deferred"] + }, + "SessionMessageAgentSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" + } + }, + "required": ["id", "time", "type", "agent"], + "additionalProperties": false + }, + "SessionMessageModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["model-switched"] + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, + "SessionMessageUser": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } + }, + "type": { + "type": "string", + "enum": ["user"] + } + }, + "required": ["id", "time", "text", "type"], + "additionalProperties": false + }, + "SessionMessageSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["synthetic"] + } + }, + "required": ["id", "time", "sessionID", "text", "type"], + "additionalProperties": false + }, + "SessionMessageShell": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["shell"] + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["id", "time", "type", "callID", "command", "output"], + "additionalProperties": false + }, + "SessionMessageAssistantText": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "SessionMessageAssistantReasoning": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["reasoning"] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "id", "text"], + "additionalProperties": false + }, + "SessionMessageToolStatePending": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending"] + }, + "input": { + "type": "string" + } + }, + "required": ["status", "input"], + "additionalProperties": false + }, + "SessionMessageToolStateRunning": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["running"] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["status", "input", "structured", "content"], + "additionalProperties": false + }, + "SessionMessageToolStateCompleted": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["completed"] + }, + "input": { + "type": "object" + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + } + }, + "required": ["status", "input", "content", "structured"], + "additionalProperties": false + }, + "SessionMessageToolStateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["error"] + }, + "input": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + }, + "structured": { + "type": "object" + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["status", "input", "content", "structured", "error"], + "additionalProperties": false + }, + "SessionMessageAssistantTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tool"] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageToolStatePending" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateRunning" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateCompleted" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateError" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "id", "name", "state", "time"], + "additionalProperties": false + }, + "SessionMessageAssistant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["assistant"] + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAssistantText" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantReasoning" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantTool" + } + ] + } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "error": { + "$ref": "#/components/schemas/SessionErrorUnknown" + } + }, + "required": ["id", "time", "type", "agent", "model", "content"], + "additionalProperties": false + }, + "SessionMessageCompaction": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["compaction"] + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + }, + "summary": { + "type": "string" + }, + "include": { + "type": "string" + }, + "id": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "reason", "summary", "id", "time"], + "additionalProperties": false + }, + "SessionMessage": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionMessageUser" + }, + { + "$ref": "#/components/schemas/SessionMessageSynthetic" + }, + { + "$ref": "#/components/schemas/SessionMessageShell" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistant" + }, + { + "$ref": "#/components/schemas/SessionMessageCompaction" + } + ] + }, + "EventTuiToastShow1": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "BadRequestError": { + "type": "object", + "required": ["data", "errors", "success"], + "properties": { + "data": {}, + "errors": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "success": { + "type": "boolean", + "enum": [false] + } + } + }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } } } - } + }, + "security": [], + "tags": [ + { + "name": "control", + "description": "Control plane routes." + }, + { + "name": "global", + "description": "Global server routes." + }, + { + "name": "event", + "description": "Instance event stream route." + }, + { + "name": "config", + "description": "Experimental HttpApi config routes." + }, + { + "name": "experimental", + "description": "Experimental HttpApi read-only routes." + }, + { + "name": "file", + "description": "Experimental HttpApi file routes." + }, + { + "name": "instance", + "description": "Experimental HttpApi instance read routes." + }, + { + "name": "mcp", + "description": "Experimental HttpApi MCP routes." + }, + { + "name": "project", + "description": "Experimental HttpApi project routes." + }, + { + "name": "pty", + "description": "Experimental HttpApi PTY routes." + }, + { + "name": "question", + "description": "Question routes." + }, + { + "name": "permission", + "description": "Experimental HttpApi permission routes." + }, + { + "name": "provider", + "description": "Experimental HttpApi provider routes." + }, + { + "name": "session", + "description": "Experimental HttpApi session routes." + }, + { + "name": "sync", + "description": "Experimental HttpApi sync routes." + }, + { + "name": "v2", + "description": "Experimental v2 routes." + }, + { + "name": "v2 messages", + "description": "Experimental v2 message routes." + }, + { + "name": "tui", + "description": "Experimental HttpApi TUI routes." + }, + { + "name": "workspace", + "description": "Experimental HttpApi workspace routes." + }, + { + "name": "pty", + "description": "PTY websocket route." + } + ] } diff --git a/packages/slack/package.json b/packages/slack/package.json index b19077be04..448df66401 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 641f0667ca..1bc70c15ab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.30", + "version": "1.14.35", "type": "module", "license": "MIT", "exports": { @@ -25,6 +25,8 @@ }, "scripts": { "typecheck": "tsgo --noEmit", + "test": "bun test src", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index 463a729778..172fe8d6c2 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -19,6 +19,21 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("one\nthree\n") }) + test("keeps missing final newlines from unified patches", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(text(view, "deletions")).toBe("one\ntwo") + expect(text(view, "additions")).toBe("one\nthree") + }) + test("converts legacy content into a patch", () => { const diff = { file: "a.ts", @@ -34,4 +49,20 @@ describe("session diff", () => { expect(text(view, "deletions")).toBe("one\n") expect(text(view, "additions")).toBe("two\n") }) + + test("ignores malformed persisted patches", () => { + const diff = { + file: "a.ts", + patch: + "diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) }) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index a5fbdbc5c0..bd6bed88d8 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -27,26 +27,49 @@ const cache = new Map() function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { - const [patch] = parsePatch(diff.patch) + try { + const [patch] = parsePatch(diff.patch) + const beforeLines: Array<{ text: string; newline: boolean }> = [] + const afterLines: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined - const beforeLines = [] - const afterLines = [] + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("\\")) { + if (previous === "-" || previous === " ") { + const before = beforeLines.at(-1) + if (before) before.newline = false + } + if (previous === "+" || previous === " ") { + const after = afterLines.at(-1) + if (after) after.newline = false + } + continue + } - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) - } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) - } else { - // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + if (line.startsWith("-")) { + beforeLines.push({ text: line.slice(1), newline: true }) + previous = "-" + } else if (line.startsWith("+")) { + afterLines.push({ text: line.slice(1), newline: true }) + previous = "+" + } else { + // context line (starts with ' ') + beforeLines.push({ text: line.slice(1), newline: true }) + afterLines.push({ text: line.slice(1), newline: true }) + previous = " " + } } } - } - return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + return { + before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + patch: diff.patch, + } + } catch { + return { before: "", after: "", patch: diff.patch } + } } return { before: "before" in diff && typeof diff.before === "string" ? diff.before : "", diff --git a/packages/web/package.json b/packages/web/package.json index c487d6ba42..a243a47078 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.30", + "version": "1.14.35", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/cli.mdx b/packages/web/src/content/docs/ar/cli.mdx index ab2c12fb20..8a9729436e 100644 --- a/packages/web/src/content/docs/ar/cli.mdx +++ b/packages/web/src/content/docs/ar/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### الخيارات -| الخيار | المختصر | الوصف | -| ------------ | ------- | ----------------------------------------------------------------- | -| `--continue` | `-c` | متابعة الجلسة الأخيرة | -| `--session` | `-s` | معرّف الجلسة للمتابعة | -| `--fork` | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | -| `--prompt` | | الموجّه المراد استخدامه | -| `--model` | `-m` | النموذج المراد استخدامه بصيغة provider/model | -| `--agent` | | الوكيل المراد استخدامه | -| `--port` | | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | | اسم المضيف الذي يتم الاستماع عليه | +| الخيار | المختصر | الوصف | +| ---------------------------------------- | ------- | ----------------------------------------------------------------- | +| {"--continue"} | `-c` | متابعة الجلسة الأخيرة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | +| {"--prompt"} | | الموجّه المراد استخدامه | +| {"--model"} | `-m` | النموذج المراد استخدامه بصيغة provider/model | +| {"--agent"} | | الوكيل المراد استخدامه | +| {"--port"} | | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | | اسم المضيف الذي يتم الاستماع عليه | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### الرايات -| الراية | المختصر | الوصف | -| ----------- | ------- | ----------------------------------- | -| `--dir` | | دليل العمل الذي ستبدأ منه واجهة TUI | -| `--session` | `-s` | معرّف الجلسة للمتابعة | +| الراية | المختصر | الوصف | +| ---------------------------------------- | ------- | --------------------------------------------------------------------------------- | +| {"--dir"} | | دليل العمل الذي ستبدأ منه واجهة TUI | +| {"--continue"} | `-c` | متابعة آخر جلسة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (استخدمه مع `--continue` أو `--session`) | +| {"--password"} | `-p` | كلمة مرور المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | اسم مستخدم المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_USERNAME` أو `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### الرايات -| الراية | الوصف | -| --------- | ------------------------------------ | -| `--event` | حدث GitHub مُحاكى لتشغيل الوكيل عليه | -| `--token` | رمز وصول شخصي لـ GitHub | +| الراية | الوصف | +| ------------------------------------- | ------------------------------------ | +| {"--event"} | حدث GitHub مُحاكى لتشغيل الوكيل عليه | +| {"--token"} | رمز وصول شخصي لـ GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### الرايات -| الراية | الوصف | -| ----------- | ------------------------------------------------------------- | -| `--refresh` | تحديث ذاكرة التخزين المؤقت للنماذج من models.dev | -| `--verbose` | استخدام مخرجات أكثر تفصيلا للنماذج (تشمل بيانات مثل التكاليف) | +| الراية | الوصف | +| --------------------------------------- | ------------------------------------------------------------- | +| {"--refresh"} | تحديث ذاكرة التخزين المؤقت للنماذج من models.dev | +| {"--verbose"} | استخدام مخرجات أكثر تفصيلا للنماذج (تشمل بيانات مثل التكاليف) | استخدم الراية `--refresh` لتحديث قائمة النماذج المخزنة مؤقتا. يفيد ذلك عند إضافة نماذج جديدة إلى مزود وتريد رؤيتها في OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### الرايات -| الراية | المختصر | الوصف | -| ------------ | ------- | ----------------------------------------------------------------- | -| `--command` | | الأمر المراد تشغيله؛ استخدم الرسالة كوسائط | -| `--continue` | `-c` | متابعة الجلسة الأخيرة | -| `--session` | `-s` | معرّف الجلسة للمتابعة | -| `--fork` | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | -| `--share` | | مشاركة الجلسة | -| `--model` | `-m` | النموذج المراد استخدامه بصيغة provider/model | -| `--agent` | | الوكيل المراد استخدامه | -| `--file` | `-f` | ملف/ملفات لإرفاقها بالرسالة | -| `--format` | | التنسيق: default (منسق) أو json (أحداث JSON خام) | -| `--title` | | عنوان للجلسة (يستخدم موجهًا مقتطعًا إن لم تُحدَّد قيمة) | -| `--attach` | | الإرفاق بخادم opencode قيد التشغيل (مثل http://localhost:4096) | -| `--port` | | منفذ الخادم المحلي (الافتراضي منفذ عشوائي) | +| الراية | المختصر | الوصف | +| ---------------------------------------- | ------- | --------------------------------------------------------------------------------- | +| {"--command"} | | الأمر المراد تشغيله؛ استخدم الرسالة كوسائط | +| {"--continue"} | `-c` | متابعة الجلسة الأخيرة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | +| {"--share"} | | مشاركة الجلسة | +| {"--model"} | `-m` | النموذج المراد استخدامه بصيغة provider/model | +| {"--agent"} | | الوكيل المراد استخدامه | +| {"--file"} | `-f` | ملف/ملفات لإرفاقها بالرسالة | +| {"--format"} | | التنسيق: default (منسق) أو json (أحداث JSON خام) | +| {"--title"} | | عنوان للجلسة (يستخدم موجهًا مقتطعًا إن لم تُحدَّد قيمة) | +| {"--attach"} | | الإرفاق بخادم opencode قيد التشغيل (مثل http://localhost:4096) | +| {"--password"} | `-p` | كلمة مرور المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | اسم مستخدم المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_USERNAME` أو `opencode`) | +| {"--dir"} | | دليل التشغيل، أو المسار على الخادم البعيد عند الإرفاق | +| {"--variant"} | | متغير النموذج (جهد الاستدلال الخاص بالمزود) | +| {"--thinking"} | | عرض كتل التفكير | +| {"--port"} | | منفذ الخادم المحلي (الافتراضي منفذ عشوائي) | --- @@ -364,12 +373,12 @@ opencode serve #### الرايات -| الراية | الوصف | -| ------------ | ----------------------------------------- | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | -| `--mdns` | تفعيل اكتشاف mDNS | -| `--cors` | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | +| الراية | الوصف | +| ---------------------------------------- | ----------------------------------------- | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | +| {"--mdns"} | تفعيل اكتشاف mDNS | +| {"--cors"} | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### الرايات -| الراية | المختصر | الوصف | -| ------------- | ------- | ------------------------------------- | -| `--max-count` | `-n` | حصر النتائج في أحدث N جلسات | -| `--format` | | تنسيق المخرجات: table أو json (table) | +| الراية | المختصر | الوصف | +| ----------------------------------------- | ------- | ------------------------------------- | +| {"--max-count"} | `-n` | حصر النتائج في أحدث N جلسات | +| {"--format"} | | تنسيق المخرجات: table أو json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### الرايات -| الراية | الوصف | -| ----------- | ------------------------------------------------------------------------- | -| `--days` | عرض الإحصاءات لآخر N يومًا (الافتراضي: كل الوقت) | -| `--tools` | عدد الأدوات المطلوب عرضها (الافتراضي: الكل) | -| `--models` | عرض تفصيل استخدام النماذج (مخفي افتراضيا). مرّر رقمًا لعرض أعلى N | -| `--project` | التصفية حسب المشروع (الافتراضي: كل المشاريع، سلسلة فارغة: المشروع الحالي) | +| الراية | الوصف | +| --------------------------------------- | ------------------------------------------------------------------------- | +| {"--days"} | عرض الإحصاءات لآخر N يومًا (الافتراضي: كل الوقت) | +| {"--tools"} | عدد الأدوات المطلوب عرضها (الافتراضي: الكل) | +| {"--models"} | عرض تفصيل استخدام النماذج (مخفي افتراضيا). مرّر رقمًا لعرض أعلى N | +| {"--project"} | التصفية حسب المشروع (الافتراضي: كل المشاريع، سلسلة فارغة: المشروع الحالي) | --- @@ -460,12 +469,12 @@ opencode web #### الرايات -| الراية | الوصف | -| ------------ | ----------------------------------------- | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | -| `--mdns` | تفعيل اكتشاف mDNS | -| `--cors` | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | +| الراية | الوصف | +| ---------------------------------------- | ----------------------------------------- | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | +| {"--mdns"} | تفعيل اكتشاف mDNS | +| {"--cors"} | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | --- @@ -481,11 +490,11 @@ opencode acp #### الرايات -| الراية | الوصف | -| ------------ | --------------------------------- | -| `--cwd` | دليل العمل | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | +| الراية | الوصف | +| ---------------------------------------- | --------------------------------- | +| {"--cwd"} | دليل العمل | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | --- @@ -499,12 +508,12 @@ opencode uninstall #### الرايات -| الراية | المختصر | الوصف | -| --------------- | ------- | ----------------------------------- | -| `--keep-config` | `-c` | الإبقاء على ملفات التهيئة | -| `--keep-data` | `-d` | الإبقاء على بيانات الجلسات واللقطات | -| `--dry-run` | | عرض ما سيتم حذفه دون تنفيذ الحذف | -| `--force` | `-f` | تخطي مطالبات التأكيد | +| الراية | المختصر | الوصف | +| ------------------------------------------- | ------- | ----------------------------------- | +| {"--keep-config"} | `-c` | الإبقاء على ملفات التهيئة | +| {"--keep-data"} | `-d` | الإبقاء على بيانات الجلسات واللقطات | +| {"--dry-run"} | | عرض ما سيتم حذفه دون تنفيذ الحذف | +| {"--force"} | `-f` | تخطي مطالبات التأكيد | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### الرايات -| الراية | المختصر | الوصف | -| ---------- | ------- | ----------------------------------------------------------- | -| `--method` | `-m` | طريقة التثبيت المستخدمة: curl أو npm أو pnpm أو bun أو brew | +| الراية | المختصر | الوصف | +| -------------------------------------- | ------- | ----------------------------------------------------------- | +| {"--method"} | `-m` | طريقة التثبيت المستخدمة: curl أو npm أو pnpm أو bun أو brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 يدعم سطر أوامر opencode الخيارات العامة التالية. -| الراية | المختصر | الوصف | -| -------------- | ------- | -------------------------------------- | -| `--help` | `-h` | عرض المساعدة | -| `--version` | `-v` | طباعة رقم الإصدار | -| `--print-logs` | | طباعة السجلات إلى stderr | -| `--log-level` | | مستوى السجل (DEBUG, INFO, WARN, ERROR) | +| الراية | المختصر | الوصف | +| ------------------------------------------ | ------- | -------------------------------------- | +| {"--help"} | `-h` | عرض المساعدة | +| {"--version"} | `-v` | طباعة رقم الإصدار | +| {"--print-logs"} | | طباعة السجلات إلى stderr | +| {"--log-level"} | | مستوى السجل (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ar/modes.mdx b/packages/web/src/content/docs/ar/modes.mdx deleted file mode 100644 index ed17670a55..0000000000 --- a/packages/web/src/content/docs/ar/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: الأوضاع -description: أوضاع مختلفة لحالات استخدام مختلفة. ---- - -:::caution -يتم الآن ضبط الأوضاع عبر خيار `agent` في إعدادات opencode. أصبح خيار -`mode` مُهمَلًا الآن. [اعرف المزيد](/docs/agents). -::: - -تتيح لك الأوضاع في opencode تخصيص السلوك والأدوات والمطالبات لحالات استخدام مختلفة. - -يأتي مع وضعين مدمجين: **build** و **plan**. يمكنك تخصيصهما أو إعداد -أوضاعك الخاصة عبر إعدادات opencode. - -يمكنك التبديل بين الأوضاع أثناء الجلسة أو إعدادها في ملف الإعدادات لديك. - ---- - -## الأوضاع المدمجة - -يأتي opencode مع وضعين مدمجين. - ---- - -### Build - -وضع Build هو الوضع **الافتراضي** مع تفعيل جميع الأدوات. هذا هو الوضع القياسي لأعمال التطوير عندما تحتاج إلى وصول كامل لعمليات الملفات وأوامر النظام. - ---- - -### Plan - -وضع مقيَّد مُصمَّم للتخطيط والتحليل. في وضع plan، تكون الأدوات التالية مُعطَّلة افتراضيًا: - -- `write` - لا يمكن إنشاء ملفات جديدة -- `edit` - لا يمكن تعديل الملفات الموجودة، باستثناء الملفات الموجودة في `.opencode/plans/*.md` لتفصيل الخطة نفسها -- `patch` - لا يمكن تطبيق التصحيحات -- `bash` - لا يمكن تنفيذ أوامر shell - -يكون هذا الوضع مفيدًا عندما تريد من الذكاء الاصطناعي تحليل الشيفرة، أو اقتراح تغييرات، أو إنشاء خطط دون إجراء أي تعديلات فعلية على قاعدة الشيفرة لديك. - ---- - -## التبديل - -يمكنك التبديل بين الأوضاع أثناء الجلسة باستخدام مفتاح _Tab_، أو اختصار `switch_mode` الذي قمت بإعداده. - -انظر أيضًا: [Formatters](/docs/formatters) لمعلومات حول إعدادات تنسيق الشيفرة. - ---- - -## الإعداد - -يمكنك تخصيص الأوضاع المدمجة أو إنشاء أوضاعك الخاصة عبر الإعدادات. يمكن إعداد الأوضاع بطريقتين: - -### إعدادات JSON - -قم بإعداد الأوضاع في ملف الإعدادات `opencode.json` لديك: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### إعدادات Markdown - -يمكنك أيضًا تعريف الأوضاع باستخدام ملفات markdown. ضعها في: - -- عالمي: `~/.config/opencode/modes/` -- للمشروع: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -يصبح اسم ملف markdown هو اسم الوضع (على سبيل المثال، `review.md` ينشئ وضعًا باسم `review`). - -لنلقِ نظرة على خيارات الإعداد هذه بمزيد من التفصيل. - ---- - -### النموذج - -استخدم إعداد `model` لتجاوز النموذج الافتراضي لهذا الوضع. يفيد ذلك عند استخدام نماذج مختلفة مُحسَّنة لمهام مختلفة؛ مثل نموذج أسرع للتخطيط ونموذج أكثر قدرة للتنفيذ. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### درجة الحرارة - -تحكَّم في العشوائية والإبداع في ردود الذكاء الاصطناعي عبر إعداد `temperature`. القيم الأقل تجعل الردود أكثر تركيزًا وحتمية، بينما تزيد القيم الأعلى الإبداع والتنوّع. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -تتراوح قيم درجة الحرارة عادةً بين 0.0 و 1.0: - -- **0.0-0.2**: ردود شديدة التركيز وحتمية، مثالية لتحليل الشيفرة والتخطيط -- **0.3-0.5**: ردود متوازنة مع قدر من الإبداع، مناسبة لمهام التطوير العامة -- **0.6-1.0**: ردود أكثر إبداعًا وتنوّعًا، مفيدة للعصف الذهني والاستكشاف - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -إذا لم يتم تحديد درجة الحرارة، يستخدم opencode القيم الافتراضية الخاصة بكل نموذج (عادةً 0 لمعظم النماذج و 0.55 لنماذج Qwen). - ---- - -### الموجّه - -حدِّد ملف موجّه نظام (system prompt) مخصص لهذا الوضع عبر إعداد `prompt`. ينبغي أن يحتوي ملف الموجّه على تعليمات مرتبطة بهدف هذا الوضع. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -هذا المسار نسبي بالنسبة لموقع ملف الإعدادات. لذلك يعمل مع -إعدادات opencode العالمية وكذلك إعدادات المشروع. - ---- - -### الأدوات - -تحكَّم في الأدوات المتاحة في هذا الوضع عبر إعداد `tools`. يمكنك تفعيل أدوات محددة أو تعطيلها بضبطها على `true` أو `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -إذا لم يتم تحديد أدوات، فستكون جميع الأدوات مفعّلة افتراضيًا. - ---- - -#### الأدوات المتاحة - -فيما يلي جميع الأدوات التي يمكن التحكم بها عبر إعدادات الوضع. - -| الأداة | الوصف | -| ----------- | -------------------------- | -| `bash` | تنفيذ أوامر shell | -| `edit` | تعديل الملفات الموجودة | -| `write` | إنشاء ملفات جديدة | -| `read` | قراءة محتويات الملفات | -| `grep` | البحث في محتويات الملفات | -| `glob` | العثور على الملفات حسب نمط | -| `patch` | تطبيق تصحيحات على الملفات | -| `todowrite` | إدارة قوائم المهام | -| `webfetch` | جلب محتوى الويب | - ---- - -## أوضاع مخصصة - -يمكنك إنشاء أوضاعك المخصصة بإضافتها إلى الإعدادات. فيما يلي أمثلة باستخدام كلا الأسلوبين: - -### باستخدام إعدادات JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### باستخدام ملفات markdown - -أنشئ ملفات الأوضاع في `.opencode/modes/` لأوضاع خاصة بالمشروع أو في `~/.config/opencode/modes/` لأوضاع عالمية: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### حالات الاستخدام - -فيما يلي بعض حالات الاستخدام الشائعة لأوضاع مختلفة. - -- **Build mode**: أعمال تطوير كاملة مع تفعيل جميع الأدوات -- **Plan mode**: التحليل والتخطيط دون إجراء تغييرات -- **Review mode**: مراجعة الشيفرة مع وصول للقراءة فقط بالإضافة إلى أدوات التوثيق -- **Debug mode**: تركيز على الاستقصاء مع تفعيل أدوات `bash` و `read` -- **Docs mode**: كتابة التوثيق مع عمليات الملفات لكن دون أوامر النظام - -قد تجد أيضًا أن نماذج مختلفة تكون أنسب لحالات استخدام مختلفة. diff --git a/packages/web/src/content/docs/ar/providers.mdx b/packages/web/src/content/docs/ar/providers.mdx index 07a19b8ad2..c4812fe5d5 100644 --- a/packages/web/src/content/docs/ar/providers.mdx +++ b/packages/web/src/content/docs/ar/providers.mdx @@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص --- -### Firmware +### FrogBot -1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. +1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. -2. شغّل الأمر `/connect` وابحث عن **Firmware**. +2. شغّل الأمر `/connect` وابحث عن **FrogBot**. ```txt /connect ``` -3. أدخل مفتاح API الخاص بـ Firmware. +3. أدخل مفتاح API الخاص بـ FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx index 118b81ba4e..c7944e7cf6 100644 --- a/packages/web/src/content/docs/bs/cli.mdx +++ b/packages/web/src/content/docs/bs/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opcije -| Opcija | Kratko | Opis | -| ------------ | ------ | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Nastavite posljednju sesiju | -| `--session` | `-s` | ID sesije za nastavak | -| `--fork` | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | -| `--prompt` | | Prompt za upotrebu | -| `--model` | `-m` | Model za korištenje u obliku provider/model | -| `--agent` | | Agent za korištenje | -| `--port` | | Port na kojem treba slušati | -| `--hostname` | | Hostname na kojem treba slušati | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--prompt"} | | Prompt za upotrebu | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--port"} | | Port na kojem treba slušati | +| {"--hostname"} | | Hostname na kojem treba slušati | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opcije -| Opcija | Kratko | Opis | -| ----------- | ------ | ------------------------------------ | -| `--dir` | | Radni direktorij za pokretanje TUI-a | -| `--session` | `-s` | ID sesije za nastavak | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | Radni direktorij za pokretanje TUI-a | +| {"--continue"} | `-c` | Nastavi posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkuj sesiju prilikom nastavka (koristite sa `--continue` ili `--session`) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opcije -| Opcija | Opis | -| --------- | -------------------------------------- | -| `--event` | GitHub mock event za pokretanje agenta | -| `--token` | GitHub Personal Access Token | +| Opcija | Opis | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event za pokretanje agenta | +| {"--token"} | GitHub Personal Access Token | --- @@ -293,10 +297,10 @@ opencode models anthropic #### Opcije -| Opcija | Opis | -| ----------- | ------------------------------------------------------------------------ | -| `--refresh` | Osvježite keš modela sa models.dev | -| `--verbose` | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | +| Opcija | Opis | +| --------------------------------------- | ------------------------------------------------------------------------ | +| {"--refresh"} | Osvježite keš modela sa models.dev | +| {"--verbose"} | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | Koristite `--refresh` zastavicu da ažurirate keširanu listu modela. Ovo je korisno kada su novi modeli dodani provajderu i želite da ih vidite u OpenCode. @@ -332,20 +336,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Opcije -| Opcija | Kratko | Opis | -| ------------ | ------ | ------------------------------------------------------------------------ | -| `--command` | | Naredba za pokretanje, koristite poruku za argumente | -| `--continue` | `-c` | Nastavite posljednju sesiju | -| `--session` | `-s` | ID sesije za nastavak | -| `--fork` | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | -| `--share` | | Podijelite sesiju | -| `--model` | `-m` | Model za korištenje u obliku provider/model | -| `--agent` | | Agent za korištenje | -| `--file` | `-f` | Fajlovi koje treba priložiti poruci | -| `--format` | | Format: default (formatiran) ili json (sirovi JSON događaji) | -| `--title` | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | -| `--attach` | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | -| `--port` | | Port za lokalni server (zadano na nasumični port) | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Naredba za pokretanje, koristite poruku za argumente | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--share"} | | Podijelite sesiju | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--file"} | `-f` | Fajlovi koje treba priložiti poruci | +| {"--format"} | | Format: default (formatiran) ili json (sirovi JSON događaji) | +| {"--title"} | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | +| {"--attach"} | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | +| {"--dir"} | | Direktorij za pokretanje, ili putanja na udaljenom serveru pri spajanju | +| {"--variant"} | | Varijanta modela (napor zaključivanja specifičan za provajdera) | +| {"--thinking"} | | Prikaži blokove razmišljanja | +| {"--port"} | | Port za lokalni server (zadano na nasumični port) | --- @@ -361,12 +370,12 @@ Ovo pokreće HTTP server koji pruža API pristup funkcionalnosti OpenCode-a bez #### Opcije -| Opcija | Opis | -| ------------ | ----------------------------------------------------- | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem treba slušati | -| `--mdns` | Omogući mDNS otkrivanje | -| `--cors` | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | --- @@ -390,10 +399,10 @@ opencode session list ##### Opcije -| Opcija | Kratko | Opis | -| ------------- | ------ | -------------------------------------- | -| `--max-count` | `-n` | Ograničenje na N najnovijih sesija | -| `--format` | | Izlazni format: table ili json (table) | +| Opcija | Kratko | Opis | +| ----------------------------------------- | ------ | -------------------------------------- | +| {"--max-count"} | `-n` | Ograničenje na N najnovijih sesija | +| {"--format"} | | Izlazni format: table ili json (table) | --- @@ -407,12 +416,12 @@ opencode stats #### Opcije -| Opcija | Opis | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `--days` | Prikaži statistiku za zadnjih N dana (sva vremena) | -| `--tools` | Broj alata za prikaz (svi) | -| `--models` | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | -| `--project` | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | +| Opcija | Opis | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Prikaži statistiku za zadnjih N dana (sva vremena) | +| {"--tools"} | Broj alata za prikaz (svi) | +| {"--models"} | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | +| {"--project"} | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | --- @@ -457,12 +466,12 @@ Ovo pokreće HTTP server i otvara web pretraživač za pristup OpenCode-u preko #### Opcije -| Opcija | Opis | -| ------------ | ----------------------------------------------------- | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem treba slušati | -| `--mdns` | Omogući mDNS otkrivanje | -| `--cors` | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | --- @@ -478,11 +487,11 @@ Ova naredba pokreće ACP server koji komunicira preko stdin/stdout koristeći nd #### Opcije -| Opcija | Opis | -| ------------ | --------------------------- | -| `--cwd` | Radni direktorij | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem slušati | +| Opcija | Opis | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Radni direktorij | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem slušati | --- @@ -496,12 +505,12 @@ opencode uninstall #### Opcije -| Opcija | Kratko | Opis | -| --------------- | ------ | --------------------------------------------- | -| `--keep-config` | `-c` | Sačuvajte konfiguracijske datoteke | -| `--keep-data` | `-d` | Sačuvajte podatke i snimke sesije | -| `--dry-run` | | Pokažite šta bi bilo uklonjeno bez uklanjanja | -| `--force` | `-f` | Preskoči upite za potvrdu | +| Opcija | Kratko | Opis | +| ------------------------------------------- | ------ | --------------------------------------------- | +| {"--keep-config"} | `-c` | Sačuvajte konfiguracijske datoteke | +| {"--keep-data"} | `-d` | Sačuvajte podatke i snimke sesije | +| {"--dry-run"} | | Pokažite šta bi bilo uklonjeno bez uklanjanja | +| {"--force"} | `-f` | Preskoči upite za potvrdu | --- @@ -527,9 +536,9 @@ opencode upgrade v0.1.48 #### Opcije -| Opcija | Kratko | Opis | -| ---------- | ------ | ------------------------------------------------------- | -| `--method` | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | +| Opcija | Kratko | Opis | +| -------------------------------------- | ------ | ------------------------------------------------------- | +| {"--method"} | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | --- @@ -537,12 +546,12 @@ opencode upgrade v0.1.48 OpenCode CLI prihvata sljedeće globalne zastavice. -| Opcija | Kratko | Opis | -| -------------- | ------ | ----------------------------------------- | -| `--help` | `-h` | Prikaži pomoć | -| `--version` | `-v` | Ispiši broj verzije | -| `--print-logs` | | Ispis logova u stderr | -| `--log-level` | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | +| Opcija | Kratko | Opis | +| ------------------------------------------ | ------ | ----------------------------------------- | +| {"--help"} | `-h` | Prikaži pomoć | +| {"--version"} | `-v` | Ispiši broj verzije | +| {"--print-logs"} | | Ispis logova u stderr | +| {"--log-level"} | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/bs/modes.mdx b/packages/web/src/content/docs/bs/modes.mdx deleted file mode 100644 index d5f92a9f67..0000000000 --- a/packages/web/src/content/docs/bs/modes.mdx +++ /dev/null @@ -1,314 +0,0 @@ ---- -title: Načini rada -description: Različiti načini za različite slučajeve upotrebe. ---- - -:::caution -Načini se sada konfiguriraju preko opcije `agent` u konfiguraciji otvorenog koda. The -`mode` opcija je sada zastarjela. [Saznajte više](/docs/agents). -::: - -Režimi u otvorenom kodu omogućavaju vam da prilagodite ponašanje, alate i upite za različite slučajeve upotrebe. -Dolazi sa dva ugrađena načina rada: **build** i **plan**. Možete prilagoditi -ove ili konfigurirajte svoje putem opencode config. -Možete se prebacivati ​​između režima tokom sesije ili ih konfigurisati u svom konfiguracionom fajlu. - ---- - -## Ugrađeni - -opencode dolazi sa dva ugrađena načina rada. - -### Build - -Build je **podrazumijevani** režim sa svim omogućenim alatima. Ovo je standardni način rada za razvoj kada vam treba pun pristup fajlovima i sistemskim komandama. - -### Plan - -Ograničeni način rada dizajniran za planiranje i analizu. U načinu plana, sljedeći alati su onemogućeni prema zadanim postavkama: - -- `write` - Ne mogu kreirati nove fajlove -- `edit` - Ne mogu modificirati postojeće fajlove, osim fajlova koji se nalaze na `.opencode/plans/*.md` radi detaljiziranja samog plana -- `patch` - Ne mogu primijeniti zakrpe -- `bash` - Ne mogu izvršiti naredbe ljuske - Ovaj način rada je koristan kada želite da AI analizira kod, predlaže promjene ili kreira planove bez ikakvih stvarnih modifikacija u vašoj bazi kodova. - ---- - -## Prebacivanje - -Možete se prebacivati ​​između načina rada tokom sesije pomoću tipke _Tab_. Ili vaše konfigurirano `switch_mode` spajanje tipki. -Vidi također: [Formatters](/docs/formatters) za informacije o konfiguraciji formatiranja koda. - ---- - -## Konfiguracija - -Možete prilagoditi ugrađene načine rada ili kreirati vlastite kroz konfiguraciju. Modovi se mogu konfigurirati na dva načina: - -### JSON konfiguracija - -Konfigurirajte načine rada u svom `opencode.json` konfiguracijskom fajlu: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown konfiguracija - -Također možete definirati načine rada koristeći markdown datoteke. Postavite ih u: - -- Globalno: `~/.config/opencode/modes/` -- Projekat: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Naziv datoteke s uštedom postaje naziv načina (npr. `review.md` kreira `review` način rada). -Pogledajmo ove opcije konfiguracije detaljno. - ---- - -### Model - -Koristite `model` konfiguraciju da nadjačate zadani model za ovaj način rada. Korisno za korištenje različitih modela optimiziranih za različite zadatke. Na primjer, brži model za planiranje, sposobniji model za implementaciju. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Kontrolišite slučajnost i kreativnost odgovora AI pomoću `temperature` konfiguracije. Niže vrijednosti čine odgovore fokusiranijim i determinističkim, dok veće vrijednosti povećavaju kreativnost i varijabilnost. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Vrijednosti temperature obično se kreću od 0,0 do 1,0: - -- **0,0-0,2**: Vrlo fokusirani i deterministički odgovori, idealni za analizu i planiranje koda -- **0,3-0,5**: Uravnoteženi odgovori sa malo kreativnosti, dobro za opšte razvojne zadatke -- **0,6-1,0**: kreativniji i raznovrsniji odgovori, korisni za razmišljanje i istraživanje - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Ako temperatura nije navedena, opencode koristi podrazumijevane postavke specifične za model (obično 0 za većinu modela i 0.55 za Qwen modele). - -### Upit - -Navedite prilagođenu sistemsku datoteku prompta za ovaj način rada s konfiguracijom `prompt`. Datoteka s promptom treba da sadrži upute specifične za svrhu načina rada. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ova putanja je relativna u odnosu na mjesto gdje se nalazi konfiguracijski fajl. Dakle, ovo radi za -i globalnu konfiguraciju otvorenog koda i konfiguraciju specifične za projekat. - ---- - -### Alati - -Kontrolirajte koji su alati dostupni u ovom načinu rada pomoću `tools` konfiguracije. Možete omogućiti ili onemogućiti određene alate tako što ćete ih postaviti na `true` ili `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Ako nijedan alat nije specificiran, svi alati su omogućeni po defaultu. - -#### Dostupni alati - -Ovdje su svi alati koji se mogu kontrolirati kroz konfiguraciju načina rada. -| Alat | Opis -|----------- | ----------------------- | -| `bash` | Izvrši naredbe ljuske | -| `edit` | Izmijenite postojeće datoteke | -| `write` | Kreirajte nove fajlove | -| `read` | Pročitajte sadržaj datoteke | -| `grep` | Pretraži sadržaj datoteke | -| `glob` | Pronađite datoteke po uzorku | -| `patch` | Primijenite zakrpe na datoteke | -| `todowrite` | Upravljanje listama zadataka | -| `webfetch` | Dohvati web sadržaj | - ---- - -## Prilagođeni načini rada - -Možete kreirati vlastite prilagođene modove tako što ćete ih dodati u konfiguraciju. Evo primjera koji koriste oba pristupa: - -### Korištenje JSON konfiguracije - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Korištenje markdown fajlova - -Kreirajte fajlove načina u `.opencode/modes/` za specifične načine rada ili `~/.config/opencode/modes/` za globalne načine: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Slučajevi upotrebe - -Evo nekoliko uobičajenih slučajeva upotrebe za različite načine rada. - -- **Build mode**: Potpuni razvojni rad sa svim omogućenim alatima -- **Plan mode**: Analiza i planiranje bez izmjena -- **Review mode**: Pregled koda sa pristupom samo za čitanje plus alati za dokumentaciju -- **Debug mode**: Fokusiran na istragu sa omogućenim bash i alatima za čitanje -- **Docs mode**: Pisanje dokumentacije sa operacijama datoteka, ali bez sistemskih naredbi - Možda ćete također otkriti da su različiti modeli dobri za različite slučajeve upotrebe. diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx index 4087db8cde..f6e54fc6ad 100644 --- a/packages/web/src/content/docs/bs/providers.mdx +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju. --- -### Firmware +### FrogBot -1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ. +1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ. -2. Pokrenite naredbu `/connect` i potražite **Firmware**. +2. Pokrenite naredbu `/connect` i potražite **FrogBot**. ```txt /connect ``` -3. Unesite svoj Firmware API ključ. +3. Unesite svoj FrogBot API ključ. ```txt ┌ API key diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 7249f4dc90..8ecb6a6eb9 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -29,16 +29,19 @@ opencode [project] #### Flags -| Flag | Short | Description | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) | -| `--prompt` | | Prompt to use | -| `--model` | `-m` | Model to use in the form of provider/model | -| `--agent` | | Agent to use | -| `--port` | | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Short | Description | +| ------------------------------------------- | ----- | ----------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--prompt"} | | Prompt to use | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--port"} | | Port to listen on | +| {"--hostname"} | | Hostname to listen on | +| {"--mdns"} | | Enable mDNS discovery | +| {"--mdns-domain"} | | Custom mDNS domain name | +| {"--cors"} | | Additional browser origin(s) to allow CORS | --- @@ -78,10 +81,14 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ----------- | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--dir"} | | Working directory to start TUI in | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | --- @@ -97,13 +104,13 @@ This command will guide you through creating a new agent with a custom system pr #### Flags -| Flag | Description | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--path` | Directory to write the agent file to (defaults to global or `.opencode/agent` based on the prompt) | -| `--description` | What the agent should do | -| `--mode` | Agent mode: `all`, `primary`, or `subagent` | -| `--permissions` | Comma-separated list of permissions to allow (default: all). Available: `bash`, `read`, `edit`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, `websearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | -| `--model`, `-m` | Model to use, in `provider/model` format | +| Flag | Short | Description | +| ------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| {"--path"} | | Directory to write the agent file to (defaults to global or `.opencode/agent` based on the prompt) | +| {"--description"} | | What the agent should do | +| {"--mode"} | | Agent mode: `all`, `primary`, or `subagent` | +| {"--permissions"} | | Comma-separated list of permissions to allow (default: all). Available: `bash`, `read`, `edit`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, `websearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | +| {"--model"} | `-m` | Model to use, in `provider/model` format | Passing all of `--path`, `--description`, `--mode`, and `--permissions` runs the command non-interactively. @@ -139,6 +146,13 @@ opencode auth login When OpenCode starts up it loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project. +##### Flags + +| Flag | Short | Description | +| ---------------------------------------- | ----- | ---------------------------------------------------- | +| {"--provider"} | `-p` | Provider ID or name to log in to | +| {"--method"} | `-m` | Login method label to use, skipping method selection | + --- #### list @@ -199,10 +213,10 @@ opencode github run ##### Flags -| Flag | Description | -| --------- | -------------------------------------- | -| `--event` | GitHub mock event to run the agent for | -| `--token` | GitHub personal access token | +| Flag | Description | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event to run the agent for | +| {"--token"} | GitHub personal access token | --- @@ -308,10 +322,10 @@ opencode models anthropic #### Flags -| Flag | Description | -| ----------- | ------------------------------------------------------------ | -| `--refresh` | Refresh the models cache from models.dev | -| `--verbose` | Use more verbose model output (includes metadata like costs) | +| Flag | Description | +| --------------------------------------- | ------------------------------------------------------------ | +| {"--refresh"} | Refresh the models cache from models.dev | +| {"--verbose"} | Use more verbose model output (includes metadata like costs) | Use the `--refresh` flag to update the cached model list. This is useful when new models have been added to a provider and you want to see them in OpenCode. @@ -347,21 +361,26 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flags -| Flag | Short | Description | -| -------------------------------- | ----- | ----------------------------------------------------------------------- | -| `--command` | | The command to run, use message for args | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) | -| `--share` | | Share the session | -| `--model` | `-m` | Model to use in the form of provider/model | -| `--agent` | | Agent to use | -| `--file` | `-f` | File(s) to attach to message | -| `--format` | | Format: default (formatted) or json (raw JSON events) | -| `--title` | | Title for the session (uses truncated prompt if no value provided) | -| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) | -| `--port` | | Port for the local server (defaults to random port) | -| `--dangerously-skip-permissions` | | Auto-approve permissions that are not explicitly denied (dangerous!) | +| Flag | Short | Description | +| ------------------------------------------------------------ | ----- | -------------------------------------------------------------------------- | +| {"--command"} | | The command to run, use message for args | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--share"} | | Share the session | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--file"} | `-f` | File(s) to attach to message | +| {"--format"} | | Format: default (formatted) or json (raw JSON events) | +| {"--title"} | | Title for the session (uses truncated prompt if no value provided) | +| {"--attach"} | | Attach to a running opencode server (e.g., http://localhost:4096) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | +| {"--dir"} | | Directory to run in, or path on the remote server when attaching | +| {"--port"} | | Port for the local server (defaults to random port) | +| {"--variant"} | | Model variant (provider-specific reasoning effort) | +| {"--thinking"} | | Show thinking blocks | +| {"--dangerously-skip-permissions"} | | Auto-approve permissions that are not explicitly denied (dangerous!) | --- @@ -377,12 +396,13 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | --- @@ -406,10 +426,20 @@ opencode session list ##### Flags -| Flag | Short | Description | -| ------------- | ----- | ------------------------------------ | -| `--max-count` | `-n` | Limit to N most recent sessions | -| `--format` | | Output format: table or json (table) | +| Flag | Short | Description | +| ----------------------------------------- | ----- | ------------------------------------ | +| {"--max-count"} | `-n` | Limit to N most recent sessions | +| {"--format"} | | Output format: table or json (table) | + +--- + +#### delete + +Delete an OpenCode session. + +```bash +opencode session delete +``` --- @@ -423,12 +453,12 @@ opencode stats #### Flags -| Flag | Description | -| ----------- | --------------------------------------------------------------------------- | -| `--days` | Show stats for the last N days (all time) | -| `--tools` | Number of tools to show (all) | -| `--models` | Show model usage breakdown (hidden by default). Pass a number to show top N | -| `--project` | Filter by project (all projects, empty string: current project) | +| Flag | Description | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--days"} | Show stats for the last N days (all time) | +| {"--tools"} | Number of tools to show (all) | +| {"--models"} | Show model usage breakdown (hidden by default). Pass a number to show top N | +| {"--project"} | Filter by project (all projects, empty string: current project) | --- @@ -442,6 +472,12 @@ opencode export [sessionID] If you don't provide a session ID, you'll be prompted to select from available sessions. +#### Flags + +| Flag | Description | +| ---------------------------------------- | ------------------------------------- | +| {"--sanitize"} | Redact sensitive transcript/file data | + --- ### import @@ -473,12 +509,13 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | --- @@ -494,11 +531,83 @@ This command starts an ACP server that communicates via stdin/stdout using nd-JS #### Flags -| Flag | Description | -| ------------ | --------------------- | -| `--cwd` | Working directory | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--cwd"} | Working directory | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | + +--- + +### plugin + +Install a plugin and update your config. + +```bash +opencode plugin +``` + +Or use the alias. + +```bash +opencode plug +``` + +#### Flags + +| Flag | Short | Description | +| -------------------------------------- | ----- | ------------------------------- | +| {"--global"} | `-g` | Install in global config | +| {"--force"} | `-f` | Replace existing plugin version | + +--- + +### pr + +Fetch and checkout a GitHub PR branch, then run OpenCode. + +```bash +opencode pr +``` + +--- + +### db + +Database tools. + +```bash +opencode db [query] +``` + +#### Flags + +| Flag | Description | +| -------------------------------------- | ------------------------------ | +| {"--format"} | Output format: `json` or `tsv` | + +--- + +#### path + +Print the database path. + +```bash +opencode db path +``` + +--- + +### debug + +Debugging and troubleshooting tools. + +```bash +opencode debug [command] +``` --- @@ -512,12 +621,12 @@ opencode uninstall #### Flags -| Flag | Short | Description | -| --------------- | ----- | ------------------------------------------- | -| `--keep-config` | `-c` | Keep configuration files | -| `--keep-data` | `-d` | Keep session data and snapshots | -| `--dry-run` | | Show what would be removed without removing | -| `--force` | `-f` | Skip confirmation prompts | +| Flag | Short | Description | +| ------------------------------------------- | ----- | ------------------------------------------- | +| {"--keep-config"} | `-c` | Keep configuration files | +| {"--keep-data"} | `-d` | Keep session data and snapshots | +| {"--dry-run"} | | Show what would be removed without removing | +| {"--force"} | `-f` | Skip confirmation prompts | --- @@ -543,9 +652,9 @@ opencode upgrade v0.1.48 #### Flags -| Flag | Short | Description | -| ---------- | ----- | ----------------------------------------------------------------- | -| `--method` | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | +| Flag | Short | Description | +| -------------------------------------- | ----- | ----------------------------------------------------------------- | +| {"--method"} | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | --- @@ -553,12 +662,13 @@ opencode upgrade v0.1.48 The opencode CLI takes the following global flags. -| Flag | Short | Description | -| -------------- | ----- | ------------------------------------ | -| `--help` | `-h` | Display help | -| `--version` | `-v` | Print version number | -| `--print-logs` | | Print logs to stderr | -| `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) | +| Flag | Short | Description | +| ------------------------------------------ | ----- | ------------------------------------ | +| {"--help"} | `-h` | Display help | +| {"--version"} | `-v` | Print version number | +| {"--print-logs"} | | Print logs to stderr | +| {"--log-level"} | | Log level (DEBUG, INFO, WARN, ERROR) | +| {"--pure"} | | Run without external plugins | --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 14eefdd81c..8568ffbb9e 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -575,7 +575,16 @@ Notice that this only works if it was not installed using a package manager such ### Formatters -You can configure code formatters through the `formatter` option. +You can enable and configure code formatters through the `formatter` option. Omit it to keep formatters disabled. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom formatters. ```json title="opencode.json" { @@ -599,6 +608,34 @@ You can configure code formatters through the `formatter` option. --- +### LSP Servers + +You can enable and configure LSP servers through the `lsp` option. Omit it to keep LSP disabled. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom LSP servers. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "disabled": true + } + } +} +``` + +[Learn more about LSP servers here](/docs/lsp). + +--- + ### Permissions By default, opencode **allows all operations** without requiring explicit approval. You can change this using the `permission` option. diff --git a/packages/web/src/content/docs/da/cli.mdx b/packages/web/src/content/docs/da/cli.mdx index 45c4f08e3f..02b1b49876 100644 --- a/packages/web/src/content/docs/da/cli.mdx +++ b/packages/web/src/content/docs/da/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flag -| Flag | Kort | Beskrivelse | -| ------------ | ---- | ---------------------------------------------------------------------------- | -| `--continue` | `-c` | Fortsæt sidste session | -| `--session` | `-s` | Sessions-id for at fortsætte | -| `--fork` | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | -| `--prompt` | | Spørg om at bruge | -| `--model` | `-m` | Model til brug i form af provider/model | -| `--agent` | | Agent hos bruge | -| `--port` | | Port at lytte på | -| `--hostname` | | Værtsnavn at lytte på | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Fortsæt sidste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--prompt"} | | Spørg om at bruge | +| {"--model"} | `-m` | Model til brug i form af provider/model | +| {"--agent"} | | Agent hos bruge | +| {"--port"} | | Port at lytte på | +| {"--hostname"} | | Værtsnavn at lytte på | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flag -| Flag | Kort | Beskrivelse | -| ----------- | ---- | -------------------------------- | -| `--dir` | | Arbejdsmappe til at starte TUI i | -| `--session` | `-s` | Sessions-id for at fortsætte | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| {"--dir"} | | Arbejdsmappe til at starte TUI i | +| {"--continue"} | `-c` | Fortsæt den seneste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--password"} | `-p` | Adgangskode til basic auth (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brugernavn til basic auth (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flag -| Flag | Beskrivelse | -| --------- | ---------------------------------------------- | -| `--event` | GitHub mock begivenhed for at køre agenten for | -| `--token` | GitHub personlig adgangstoken | +| Flag | Beskrivelse | +| ------------------------------------- | ---------------------------------------------- | +| {"--event"} | GitHub mock begivenhed for at køre agenten for | +| {"--token"} | GitHub personlig adgangstoken | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flag -| Flag | Beskrivelse | -| ----------- | ----------------------------------------------------------------------- | -| `--refresh` | Opdater modelcachen fra models.dev | -| `--verbose` | Brug mere detaljeret modeloutput (inkluderer metadata som omkostninger) | +| Flag | Beskrivelse | +| --------------------------------------- | ----------------------------------------------------------------------- | +| {"--refresh"} | Opdater modelcachen fra models.dev | +| {"--verbose"} | Brug mere detaljeret modeloutput (inkluderer metadata som omkostninger) | Brug flaget `--refresh` til at opdatere den cachelagrede modelliste. Dette er nyttigt, når nye modeller er blevet tilføjet til en udbyder, og du vil se dem i OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flag -| Flag | Kort | Beskrivelse | -| ------------ | ---- | ----------------------------------------------------------------------------------- | -| `--command` | | Kommandoen til at køre, brug besked til args | -| `--continue` | `-c` | Fortsæt sidste session | -| `--session` | `-s` | Sessions-id for at fortsætte | -| `--fork` | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | -| `--share` | | Del sessionen | -| `--model` | `-m` | Model til brug i form af provider/model | -| `--agent` | | Agent til brug | -| `--file` | `-f` | Fil(er), der skal vedhæftes til meddelelsen | -| `--format` | | Format: standard (formateret) eller json (rå JSON hændelser) | -| `--title` | | Titel for sessionen (bruger trunkeret prompt, hvis der ikke er angivet nogen værdi) | -| `--attach` | | Tilslut til en kørende opencode-server (f.eks. http://localhost:4096) | -| `--port` | | Port til den lokale server (standard til vilkårlig port) | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| {"--command"} | | Kommandoen til at køre, brug besked til args | +| {"--continue"} | `-c` | Fortsæt sidste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--share"} | | Del sessionen | +| {"--model"} | `-m` | Model til brug i form af provider/model | +| {"--agent"} | | Agent til brug | +| {"--file"} | `-f` | Fil(er), der skal vedhæftes til meddelelsen | +| {"--format"} | | Format: standard (formateret) eller json (rå JSON hændelser) | +| {"--title"} | | Titel for sessionen (bruger trunkeret prompt, hvis der ikke er angivet nogen værdi) | +| {"--attach"} | | Tilslut til en kørende opencode-server (f.eks. http://localhost:4096) | +| {"--password"} | `-p` | Adgangskode til basic auth (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brugernavn til basic auth (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | +| {"--dir"} | | Mappe at køre i, eller sti på fjernserveren ved tilkobling | +| {"--variant"} | | Modelvariant (udbyder-specifik ræsonneringsindsats) | +| {"--thinking"} | | Vis tænkeblokke | +| {"--port"} | | Port til den lokale server (standard til vilkårlig port) | --- @@ -364,12 +373,12 @@ Dette starter en HTTP-server, der giver API-adgang til opencode-funktionalitet u #### Flag -| Flag | Beskrivelse | -| ------------ | ------------------------------------------------ | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | -| `--mdns` | Aktiver mDNS-opdagelse | -| `--cors` | Yderligere browseroprindelse til at tillade CORS | +| Flag | Beskrivelse | +| ---------------------------------------- | ------------------------------------------------ | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | +| {"--mdns"} | Aktiver mDNS-opdagelse | +| {"--cors"} | Yderligere browseroprindelse til at tillade CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flag -| Flag | Kort | Beskrivelse | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | Begræns til N seneste sessioner | -| `--format` | | Outputformat: tabel eller json (tabel) | +| Flag | Kort | Beskrivelse | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | Begræns til N seneste sessioner | +| {"--format"} | | Outputformat: tabel eller json (tabel) | --- @@ -410,12 +419,12 @@ opencode stats #### Flag -| Flag | Beskrivelse | -| ----------- | --------------------------------------------------------------------------- | -| `--days` | Vis statistik for de sidste N dage (hele tiden) | -| `--tools` | Antal værktøjer, der skal vises (alle) | -| `--models` | Vis modelbrugsopdeling (skjult som standard). Send et tal for at vise top N | -| `--project` | Filtre efter projekt (alle projekter, tom streng: nuværende projekt) | +| Flag | Beskrivelse | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--days"} | Vis statistik for de sidste N dage (hele tiden) | +| {"--tools"} | Antal værktøjer, der skal vises (alle) | +| {"--models"} | Vis modelbrugsopdeling (skjult som standard). Send et tal for at vise top N | +| {"--project"} | Filtre efter projekt (alle projekter, tom streng: nuværende projekt) | --- @@ -460,12 +469,12 @@ Dette starter en HTTP-server og åbner en webbrowser for at få adgang til OpenC #### Flag -| Flag | Beskrivelse | -| ------------ | ------------------------------------------------ | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | -| `--mdns` | Aktiver mDNS-opdagelse | -| `--cors` | Yderligere browseroprindelse til at tillade CORS | +| Flag | Beskrivelse | +| ---------------------------------------- | ------------------------------------------------ | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | +| {"--mdns"} | Aktiver mDNS-opdagelse | +| {"--cors"} | Yderligere browseroprindelse til at tillade CORS | --- @@ -481,11 +490,11 @@ Denne kommando starter en ACP-server, der kommunikerer via stdin/stdout ved hjæ #### Flag -| Flag | Beskrivelse | -| ------------ | --------------------- | -| `--cwd` | Arbejdsmappe | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | +| Flag | Beskrivelse | +| ---------------------------------------- | --------------------- | +| {"--cwd"} | Arbejdsmappe | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flag -| Flag | Kort | Beskrivelse | -| --------------- | ---- | ------------------------------------------------ | -| `--keep-config` | `-c` | Se konfigurationsfiler | -| `--keep-data` | `-d` | Gem sessionsdata og snapshots | -| `--dry-run` | | Vis, hvad der ville blive fjernet uden at fjerne | -| `--force` | `-f` | Spring bekræftelsesspørgsmål over | +| Flag | Kort | Beskrivelse | +| ------------------------------------------- | ---- | ------------------------------------------------ | +| {"--keep-config"} | `-c` | Se konfigurationsfiler | +| {"--keep-data"} | `-d` | Gem sessionsdata og snapshots | +| {"--dry-run"} | | Vis, hvad der ville blive fjernet uden at fjerne | +| {"--force"} | `-f` | Spring bekræftelsesspørgsmål over | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### upgrade -| Flag | Kort | Beskrivelse | -| ---------- | ---- | ---------------------------------------------------------------- | -| `--method` | `-m` | Installationsmetoden, der blev brugt; curl, npm, pnpm, bun, brew | +| Flag | Kort | Beskrivelse | +| -------------------------------------- | ---- | ---------------------------------------------------------------- | +| {"--method"} | `-m` | Installationsmetoden, der blev brugt; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI tager følgende globale flag. -| Flag | Kort | Beskrivelse | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | Vis hjælp | -| `--version` | `-v` | Udskriftsversionsnummer | -| `--print-logs` | | Udskriv logfiler til stderr | -| `--log-level` | | Logniveau (DEBUG, INFO, WARN, ERROR) | +| Flag | Kort | Beskrivelse | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | Vis hjælp | +| {"--version"} | `-v` | Udskriftsversionsnummer | +| {"--print-logs"} | | Udskriv logfiler til stderr | +| {"--log-level"} | | Logniveau (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/da/modes.mdx b/packages/web/src/content/docs/da/modes.mdx deleted file mode 100644 index a0fb87a862..0000000000 --- a/packages/web/src/content/docs/da/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Tilstande -description: Forskellige tilstande til forskellige anvendelsestilfælde. ---- - -:::caution -Tilstande er nu konfigureret gennem indstillingen `agent` i opencode-konfigurationen. De -`mode` mulighed er nu forældet. [Learn more](/docs/agents). -::: - -Tilstande i opencode giver dig mulighed for at tilpasse adfærd, værktøjer og prompter til forskellige brugstilfælde. - -Den kommer med to indbyggede tilstande: **build** og **plan**. Du kan tilpasse -disse eller konfigurer dine egne gennem opencode-konfigurationen. - -Du kan skifte mellem tilstande under en session eller konfigurere dem i din konfigurationsfil. - ---- - -## Indbyggede - -opencode leveres med to indbyggede tilstande. - ---- - -### Byg - -Byg er **standard**-tilstanden med alle værktøjer aktiveret. Dette er standardtilstanden for udviklingsarbejde, hvor du har brug for fuld adgang til filhandlinger og systemkommandoer. - ---- - -### Plan - -En begrænset tilstand designet til planlægning og analyse. I plantilstand er følgende værktøjer deaktiveret som standard: - -- `write` - Kan ikke oprette nye filer -- `edit` - Kan ikke ændre eksisterende filer, undtagen filer placeret på `.opencode/plans/*.md` for at detaljere selve planen -- `patch` - Kan ikke anvende patches -- `bash` - Kan ikke udføre shell-kommandoer - -Denne tilstand er nyttig, når du vil have AI til at analysere kode, foreslå ændringer eller oprette planer uden at foretage egentlige ændringer af din kodebase. - ---- - -## Skift - -Du kan skifte mellem tilstande under en session ved at bruge _Tab_-tasten. Eller din konfigurerede `switch_mode` nøglebinding. - -Se også: [Formatters](/docs/formatters) for information om konfiguration af kodeformatering. - ---- - -## Konfiguration - -Du kan tilpasse de indbyggede tilstande eller oprette dine egne gennem konfiguration. Tilstande kan konfigureres på to måder: - -### JSON-konfiguration - -Konfigurer tilstande i din `opencode.json`-konfigurationsfil: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-konfiguration - -Du kan også definere tilstande ved hjælp af markdown-filer. Placer dem i: - -- Globalt: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown-filnavnet bliver tilstandsnavnet (f.eks. opretter `review.md` en `review`-tilstand). - -Lad os se nærmere på disse konfigurationsmuligheder. - ---- - -### Model - -Brug `model`-konfigurationen til at tilsidesætte standardmodellen for denne tilstand. Nyttigt til brug af forskellige modeller optimeret til forskellige opgaver. For eksempel en hurtigere model til planlægning, en mere dygtig model til implementering. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Styr tilfældigheden og kreativiteten af ​​AI's svar med `temperature`-konfigurationen. Lavere værdier gør svar mere fokuserede og deterministiske, mens højere værdier øger kreativitet og variabilitet. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturværdier varierer typisk fra 0,0 til 1,0: - -- **0.0-0.2**: Meget fokuserede og deterministiske svar, ideel til kodeanalyse og planlægning -- **0,3-0,5**: Afbalancerede svar med en vis kreativitet, god til generelle udviklingsopgaver -- **0.6-1.0**: Mere kreative og varierede svar, nyttige til brainstorming og udforskning - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Hvis der ikke er angivet nogen temperatur, bruger opencode modelspecifikke standarder (typisk 0 for de fleste modeller, 0,55 for Qwen-modeller). - ---- - -### Prompt - -Angiv en brugerdefineret systempromptfil for denne tilstand med `prompt`-konfigurationen. Promptfilen skal indeholde instruktioner, der er specifikke for tilstandens formål. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Denne sti er i forhold til, hvor konfigurationsfilen er placeret. Så dette virker for -både den globale opencode-konfiguration og den projektspecifikke konfiguration. - ---- - -### Værktøjer - -Kontroller, hvilke værktøjer der er tilgængelige i denne tilstand med `tools`-konfigurationen. Du kan aktivere eller deaktivere specifikke værktøjer ved at indstille dem til `true` eller `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hvis der ikke er angivet nogen værktøjer, er alle værktøjer aktiveret som standard. - ---- - -#### Tilgængelige værktøjer - -Her er alle de værktøjer, der kan styres gennem tilstandskonfigurationen. - -| Værktøj | Beskrivelse | -| ----------- | -------------------------- | -| `bash` | Udfør shell-kommandoer | -| `edit` | Rediger eksisterende filer | -| `write` | Opret nye filer | -| `read` | Læs filindhold | -| `grep` | Søg filindhold | -| `glob` | Find filer efter mønster | -| `patch` | Anvend patches til filer | -| `todowrite` | Administrer todo-lister | -| `webfetch` | Hent webindhold | - ---- - -## Brugerdefinerede tilstande - -Du kan oprette dine egne brugerdefinerede tilstande ved at tilføje dem til konfigurationen. Her er eksempler, der bruger begge tilgange: - -### Brug af JSON-konfiguration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Brug af markdown-filer - -Opret tilstandsfiler i `.opencode/modes/` for projektspecifikke tilstande eller `~/.config/opencode/modes/` for globale tilstande: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Brugsscenarier - -Her er nogle almindelige brugstilfælde for forskellige tilstande. - -- **Build mode**: Fuldt udviklingsarbejde med alle værktøjer aktiveret -- **Plantilstand**: Analyse og planlægning uden at foretage ændringer -- **Anmeldelsestilstand**: Kodegennemgang med skrivebeskyttet adgang plus dokumentationsværktøjer -- **Fejlretningstilstand**: Fokuseret på undersøgelse med bash og læseværktøjer aktiveret -- **Docs-tilstand**: Dokumentationsskrivning med filhandlinger, men ingen systemkommandoer - -Du kan også finde ud af, at forskellige modeller er gode til forskellige brugssituationer. diff --git a/packages/web/src/content/docs/da/providers.mdx b/packages/web/src/content/docs/da/providers.mdx index 8817d23192..9b04d6be82 100644 --- a/packages/web/src/content/docs/da/providers.mdx +++ b/packages/web/src/content/docs/da/providers.mdx @@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle. +1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle. -2. Kør kommandoen `/connect` og søg efter **Firmware**. +2. Kør kommandoen `/connect` og søg efter **FrogBot**. ```txt /connect ``` -3. Indtast firmware API-nøglen. +3. Indtast frogbot API-nøglen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/de/cli.mdx b/packages/web/src/content/docs/de/cli.mdx index 43a1189d60..94e9c88fac 100644 --- a/packages/web/src/content/docs/de/cli.mdx +++ b/packages/web/src/content/docs/de/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Optionen -| Flag | Kurz | Beschreibung | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--continue` | `-c` | Setzen Sie die letzte Sitzung fort | -| `--session` | `-s` | Session-ID zum Fortfahren | -| `--fork` | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session`) | -| `--prompt` | | Prompt zur Verwendung | -| `--model` | `-m` | Zu verwendendes Modell in der Form provider/model | -| `--agent` | | Zu verwendender Agent | -| `--port` | | Port zum Abhören | -| `--hostname` | | Hostname zum Abhören | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------- | +| {"--continue"} | `-c` | Setzen Sie die letzte Sitzung fort | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session`) | +| {"--prompt"} | | Prompt zur Verwendung | +| {"--model"} | `-m` | Zu verwendendes Modell in der Form provider/model | +| {"--agent"} | | Zu verwendender Agent | +| {"--port"} | | Port zum Abhören | +| {"--hostname"} | | Hostname zum Abhören | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Optionen -| Flag | Kurz | Beschreibung | -| ----------- | ---- | ----------------------------------------- | -| `--dir` | | Arbeitsverzeichnis zum Starten von TUI in | -| `--session` | `-s` | Session-ID zum Fortfahren | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------------------- | +| {"--dir"} | | Arbeitsverzeichnis zum Starten von TUI in | +| {"--continue"} | `-c` | Letzte Sitzung fortsetzen | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen abzweigen (mit `--continue` oder `--session` verwenden) | +| {"--password"} | `-p` | Basic-Auth-Passwort (standardmäßig `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic-Auth-Benutzername (standardmäßig `OPENCODE_SERVER_USERNAME` oder `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Optionen -| Flag | Beschreibung | -| --------- | --------------------------------------------------- | -| `--event` | GitHub Scheinereignis zum Ausführen des Agenten für | -| `--token` | GitHub persönliches Zugriffstoken | +| Flag | Beschreibung | +| ------------------------------------- | --------------------------------------------------- | +| {"--event"} | GitHub Scheinereignis zum Ausführen des Agenten für | +| {"--token"} | GitHub persönliches Zugriffstoken | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Optionen -| Flag | Beschreibung | -| ----------- | ------------------------------------------------------------------------------------- | -| `--refresh` | Aktualisieren Sie den Modellcache von models.dev | -| `--verbose` | Verwenden Sie eine ausführlichere Modellausgabe (einschließlich Metadaten wie Kosten) | +| Flag | Beschreibung | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--refresh"} | Aktualisieren Sie den Modellcache von models.dev | +| {"--verbose"} | Verwenden Sie eine ausführlichere Modellausgabe (einschließlich Metadaten wie Kosten) | Verwenden Sie das Flag `--refresh`, um die zwischengespeicherte Modellliste zu aktualisieren. Dies ist nützlich, wenn einem Anbieter neue Modelle hinzugefügt wurden und Sie diese in OpenCode sehen möchten. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Optionen -| Flag | Kurz | Beschreibung | -| ------------ | ---- | --------------------------------------------------------------------------------------------------- | -| `--command` | | Der auszuführende Befehl, Argumente als Nachricht verwenden | -| `--continue` | `-c` | Setzen Sie die letzte Sitzung fort | -| `--session` | `-s` | Session-ID zum Fortfahren | -| `--fork` | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session` verwenden) | -| `--share` | | Teilen Sie die Sitzung | -| `--model` | `-m` | Zu verwendendes Modell in der Form provider/model | -| `--agent` | | Zu verwendender Agent | -| `--file` | `-f` | Datei(en) zum Anhängen an die Nachricht | -| `--format` | | Format: default (formatiert) oder json (rohe JSON-Ereignisse) | -| `--title` | | Titel für die Sitzung (verwendet eine verkürzte Eingabeaufforderung, wenn kein Wert angegeben wird) | -| `--attach` | | An einen laufenden OpenCode-Server anschließen (z.B. http://localhost:4096) | -| `--port` | | Port für den lokalen Server (standardmäßig zufälliger Port) | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------------- | +| {"--command"} | | Der auszuführende Befehl, Argumente als Nachricht verwenden | +| {"--continue"} | `-c` | Setzen Sie die letzte Sitzung fort | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session` verwenden) | +| {"--share"} | | Teilen Sie die Sitzung | +| {"--model"} | `-m` | Zu verwendendes Modell in der Form provider/model | +| {"--agent"} | | Zu verwendender Agent | +| {"--file"} | `-f` | Datei(en) zum Anhängen an die Nachricht | +| {"--format"} | | Format: default (formatiert) oder json (rohe JSON-Ereignisse) | +| {"--title"} | | Titel für die Sitzung (verwendet eine verkürzte Eingabeaufforderung, wenn kein Wert angegeben wird) | +| {"--attach"} | | An einen laufenden OpenCode-Server anschließen (z.B. http://localhost:4096) | +| {"--password"} | `-p` | Basic-Auth-Passwort (standardmäßig `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic-Auth-Benutzername (standardmäßig `OPENCODE_SERVER_USERNAME` oder `opencode`) | +| {"--dir"} | | Verzeichnis für die Ausführung, oder Pfad auf dem Remote-Server beim Anhängen | +| {"--variant"} | | Modellvariante (anbieterspezifischer Reasoning-Aufwand) | +| {"--thinking"} | | Denkblöcke anzeigen | +| {"--port"} | | Port für den lokalen Server (standardmäßig zufälliger Port) | --- @@ -364,12 +373,12 @@ Dadurch wird ein HTTP-Server gestartet, der API-Zugriff auf OpenCode-Funktionali #### Optionen -| Flag | Beschreibung | -| ------------ | ----------------------------------------------- | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | -| `--mdns` | mDNS-Discovery aktivieren | -| `--cors` | Zusätzliche Browser-Ursprünge für CORS zulassen | +| Flag | Beschreibung | +| ---------------------------------------- | ----------------------------------------------- | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | +| {"--mdns"} | mDNS-Discovery aktivieren | +| {"--cors"} | Zusätzliche Browser-Ursprünge für CORS zulassen | --- @@ -393,10 +402,10 @@ opencode session list ##### Optionen -| Flag | Kurz | Beschreibung | -| ------------- | ---- | ---------------------------------------- | -| `--max-count` | `-n` | Beschränken auf die N neuesten Sitzungen | -| `--format` | | Ausgabeformat: table oder json (table) | +| Flag | Kurz | Beschreibung | +| ----------------------------------------- | ---- | ---------------------------------------- | +| {"--max-count"} | `-n` | Beschränken auf die N neuesten Sitzungen | +| {"--format"} | | Ausgabeformat: table oder json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### Optionen -| Flag | Beschreibung | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `--days` | Statistiken für die letzten N Tage anzeigen (alle Zeiten) | -| `--tools` | Anzahl der angebotenen Werkzeuge (alle) | -| `--models` | Aufschlüsselung der Modellnutzung anzeigen (standardmäßig ausgeblendet). Übergeben Sie eine Zahl, um die obersten N anzuzeigen | -| `--project` | Nach Projekt filtern (alle Projekte, leere Zeichenfolge: aktuelles Projekt) | +| Flag | Beschreibung | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| {"--days"} | Statistiken für die letzten N Tage anzeigen (alle Zeiten) | +| {"--tools"} | Anzahl der angebotenen Werkzeuge (alle) | +| {"--models"} | Aufschlüsselung der Modellnutzung anzeigen (standardmäßig ausgeblendet). Übergeben Sie eine Zahl, um die obersten N anzuzeigen | +| {"--project"} | Nach Projekt filtern (alle Projekte, leere Zeichenfolge: aktuelles Projekt) | --- @@ -460,12 +469,12 @@ Dadurch wird ein HTTP-Server gestartet und ein Webbrowser geöffnet, um über ei #### Optionen -| Flag | Beschreibung | -| ------------ | ----------------------------------------------- | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | -| `--mdns` | mDNS-Discovery aktivieren | -| `--cors` | Zusätzliche Browser-Ursprünge für CORS zulassen | +| Flag | Beschreibung | +| ---------------------------------------- | ----------------------------------------------- | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | +| {"--mdns"} | mDNS-Discovery aktivieren | +| {"--cors"} | Zusätzliche Browser-Ursprünge für CORS zulassen | --- @@ -481,11 +490,11 @@ Dieser Befehl startet einen ACP-Server, der über stdin/stdout unter Verwendung #### Optionen -| Flag | Beschreibung | -| ------------ | -------------------- | -| `--cwd` | Arbeitsverzeichnis | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | +| Flag | Beschreibung | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | Arbeitsverzeichnis | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | --- @@ -499,12 +508,12 @@ opencode uninstall #### Optionen -| Flag | Kurz | Beschreibung | -| --------------- | ---- | --------------------------------------------------- | -| `--keep-config` | `-c` | Konfigurationsdateien behalten | -| `--keep-data` | `-d` | Sitzungsdaten und Snapshots aufbewahren | -| `--dry-run` | | Zeigt, was entfernt werden würde, ohne zu entfernen | -| `--force` | `-f` | Bestätigungsaufforderungen überspringen | +| Flag | Kurz | Beschreibung | +| ------------------------------------------- | ---- | --------------------------------------------------- | +| {"--keep-config"} | `-c` | Konfigurationsdateien behalten | +| {"--keep-data"} | `-d` | Sitzungsdaten und Snapshots aufbewahren | +| {"--dry-run"} | | Zeigt, was entfernt werden würde, ohne zu entfernen | +| {"--force"} | `-f` | Bestätigungsaufforderungen überspringen | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Optionen -| Flag | Kurz | Beschreibung | -| ---------- | ---- | ------------------------------------------------------------------- | -| `--method` | `-m` | Die zu verwendende Installationsmethode; curl, npm, pnpm, bun, brew | +| Flag | Kurz | Beschreibung | +| -------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--method"} | `-m` | Die zu verwendende Installationsmethode; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 Der OpenCode CLI akzeptiert die folgenden globalen Flags. -| Flag | Kurz | Beschreibung | -| -------------- | ---- | ----------------------------------------- | -| `--help` | `-h` | Hilfe anzeigen | -| `--version` | `-v` | Versionsnummer drucken | -| `--print-logs` | | Protokolle nach stderr drucken | -| `--log-level` | | Protokollebene (DEBUG, INFO, WARN, ERROR) | +| Flag | Kurz | Beschreibung | +| ------------------------------------------ | ---- | ----------------------------------------- | +| {"--help"} | `-h` | Hilfe anzeigen | +| {"--version"} | `-v` | Versionsnummer drucken | +| {"--print-logs"} | | Protokolle nach stderr drucken | +| {"--log-level"} | | Protokollebene (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/de/modes.mdx b/packages/web/src/content/docs/de/modes.mdx deleted file mode 100644 index 11a010d6b9..0000000000 --- a/packages/web/src/content/docs/de/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modi -description: Verschiedene Modi für unterschiedliche Anwendungsfälle. ---- - -:::caution -Modi werden jetzt über die Option `agent` in der OpenCode-Konfiguration konfiguriert. Der -Die Option `mode` ist jetzt veraltet. [Learn more](/docs/agents). -::: - -Mit den Modi in OpenCode können Sie das Verhalten, die Tools und die Eingabeaufforderungen für verschiedene Anwendungsfälle anpassen. - -Es verfügt über zwei integrierte Modi: **Build** und **Plan**. Sie können anpassen -diese oder konfigurieren Sie Ihre eigenen über die OpenCode-Konfiguration. - -Sie können während einer Sitzung zwischen den Modi wechseln oder diese in Ihrer Konfigurationsdatei konfigurieren. - ---- - -## Integriert - -OpenCode verfügt über zwei integrierte Modi. - ---- - -### Build - -Build ist der **Standardmodus**, bei dem alle Tools aktiviert sind. Dies ist der Standardmodus für Entwicklungsarbeiten, bei dem Sie vollen Zugriff auf Dateioperationen und Systembefehle benötigen. - ---- - -### Plan - -Ein eingeschränkter Modus für Planung und Analyse. Im Planmodus sind die folgenden Tools standardmäßig deaktiviert: - -- `write` – Es können keine neuen Dateien erstellt werden -- `edit` – Vorhandene Dateien können nicht geändert werden, mit Ausnahme der Dateien unter `.opencode/plans/*.md`, um den Plan selbst detailliert darzustellen -- `patch` – Patches können nicht angewendet werden -- `bash` – Shell-Befehle können nicht ausgeführt werden - -Dieser Modus ist nützlich, wenn Sie möchten, dass AI Code analysiert, Änderungen vorschlägt oder Pläne erstellt, ohne tatsächliche Änderungen an Ihrer Codebasis vorzunehmen. - ---- - -## Wechseln - -Sie können während einer Sitzung mit der _Tab_-Taste zwischen den Modi wechseln. Oder Ihre konfigurierte `switch_mode`-Tastenkombination. - -Siehe auch: [Formatters](/docs/formatters) für Informationen zur Codeformatierungskonfiguration. - ---- - -## Konfiguration - -Sie können die integrierten Modi anpassen oder über die Konfiguration eigene erstellen. Modi können auf zwei Arten konfiguriert werden: - -### JSON-Konfiguration - -Konfigurieren Sie Modi in Ihrer `opencode.json`-Konfigurationsdatei: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-Konfiguration - -Sie können Modi auch mithilfe von Markdown-Dateien definieren. Platzieren Sie sie in: - -- Global: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Der Name der Markdown-Datei wird zum Modusnamen (e.g., `review.md` erstellt einen `review`-Modus). - -Schauen wir uns diese Konfigurationsmöglichkeiten im Detail an. - ---- - -### Model - -Verwenden Sie die `model`-Konfiguration, um das Standardmodell für diesen Modus zu überschreiben. Nützlich für die Verwendung verschiedener Modelle, die für verschiedene Aufgaben optimiert sind. Zum Beispiel ein schnelleres Modell für die Planung, ein leistungsfähigeres Modell für die Umsetzung. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Steuern Sie die Zufälligkeit und Kreativität der Antworten von AI mit der `temperature`-Konfiguration. Niedrigere Werte machen die Antworten fokussierter und deterministischer, während höhere Werte die Kreativität und Variabilität steigern. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturwerte liegen typischerweise zwischen 0.0 und 1.0: - -- **0.0-0.2**: Sehr fokussierte und deterministische Antworten, ideal für Code-Analyse und Planung -- **0.3-0.5**: Ausgewogene Antworten mit etwas Kreativität, gut für allgemeine Entwicklungsaufgaben -- **0.6-1.0**: Kreativere und vielfältigere Antworten, nützlich für Brainstorming und Erkundung - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Wenn keine Temperatur angegeben ist, verwendet OpenCode modellspezifische Standardwerte (normalerweise 0 für die meisten Modelle, 0.55 für Qwen-Modelle). - ---- - -### Prompt - -Geben Sie mit der `prompt`-Konfiguration eine benutzerdefinierte Systemaufforderungsdatei für diesen Modus an. Die Eingabeaufforderungsdatei sollte spezifische Anweisungen für den Zweck des Modus enthalten. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Dieser Pfad ist relativ zum Speicherort der Konfigurationsdatei. Das funktioniert also -sowohl die globale OpenCode-Konfiguration als auch die projektspezifische Konfiguration. - ---- - -### Werkzeuge - -Steuern Sie mit der `tools`-Konfiguration, welche Tools in diesem Modus verfügbar sind. Sie können bestimmte Tools aktivieren oder deaktivieren, indem Sie sie auf `true` oder `false` setzen. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Wenn keine Tools angegeben sind, sind alle Tools standardmäßig aktiviert. - ---- - -#### Verfügbare Tools - -Hier sind alle Tools aufgeführt, die über den Konfigurationsmodus gesteuert werden können. - -| Werkzeug | Beschreibung | -| ----------- | ---------------------------- | -| `bash` | Shell-Befehle ausführen | -| `edit` | Vorhandene Dateien ändern | -| `write` | Neue Dateien erstellen | -| `read` | Dateiinhalt lesen | -| `grep` | Dateiinhalte durchsuchen | -| `glob` | Dateien nach Muster suchen | -| `patch` | Patches auf Dateien anwenden | -| `todowrite` | Aufgabenlisten verwalten | -| `webfetch` | Webinhalte abrufen | - ---- - -## Benutzerdefinierte Modi - -Sie können Ihre eigenen benutzerdefinierten Modi erstellen, indem Sie diese zur Konfiguration hinzufügen. Hier sind Beispiele für beide Ansätze: - -### Verwenden der JSON-Konfiguration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Verwendung von Markdown-Dateien - -Erstellen Sie Modusdateien in `.opencode/modes/` für projektspezifische Modi oder `~/.config/opencode/modes/` für globale Modi: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Anwendungsfälle - -Hier sind einige häufige Anwendungsfälle für verschiedene Modi. - -- **Build-Modus**: Vollständige Entwicklungsarbeit mit allen aktivierten Tools -- **Planmodus**: Analyse und Planung ohne Änderungen -- **Überprüfungsmodus**: Codeüberprüfung mit schreibgeschütztem Zugriff plus Dokumentationstools -- **Debug-Modus**: Konzentriert sich auf die Untersuchung mit aktivierten Bash- und Lesetools -- **Docs-Modus**: Dokumentationsschreiben mit Dateioperationen, aber ohne Systembefehle - -Möglicherweise stellen Sie auch fest, dass unterschiedliche Modelle für unterschiedliche Anwendungsfälle geeignet sind. diff --git a/packages/web/src/content/docs/de/providers.mdx b/packages/web/src/content/docs/de/providers.mdx index 87f78c9d22..9298146930 100644 --- a/packages/web/src/content/docs/de/providers.mdx +++ b/packages/web/src/content/docs/de/providers.mdx @@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf --- -### Firmware +### FrogBot -1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. +1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. -2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**. +2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**. ```txt /connect ``` -3. Geben Sie Ihren Firmware API-Schlüssel ein. +3. Geben Sie Ihren FrogBot API-Schlüssel ein. ```txt ┌ API key diff --git a/packages/web/src/content/docs/es/cli.mdx b/packages/web/src/content/docs/es/cli.mdx index 5c86474a61..6b66e7b5fc 100644 --- a/packages/web/src/content/docs/es/cli.mdx +++ b/packages/web/src/content/docs/es/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opciones -| Opción | Corta | Descripción | -| ------------ | ----- | --------------------------------------------------------------------- | -| `--continue` | `-c` | Continuar la última sesión | -| `--session` | `-s` | ID de sesión para continuar | -| `--fork` | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | -| `--prompt` | | Aviso de uso | -| `--model` | `-m` | Modelo a utilizar en forma de proveedor/modelo | -| `--agent` | | Agente a utilizar | -| `--port` | | Puerto para escuchar | -| `--hostname` | | Nombre de host para escuchar | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | --------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--prompt"} | | Aviso de uso | +| {"--model"} | `-m` | Modelo a utilizar en forma de proveedor/modelo | +| {"--agent"} | | Agente a utilizar | +| {"--port"} | | Puerto para escuchar | +| {"--hostname"} | | Nombre de host para escuchar | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opciones -| Opción | Corta | Descripción | -| ----------- | ----- | ----------------------------------------- | -| `--dir` | | Directorio de trabajo para iniciar TUI en | -| `--session` | `-s` | ID de sesión para continuar | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------- | +| {"--dir"} | | Directorio de trabajo para iniciar TUI en | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--password"} | `-p` | Contraseña de autenticación básica (predeterminada: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuario de autenticación básica (predeterminado: `OPENCODE_SERVER_USERNAME` u `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opciones -| Opción | Descripción | -| --------- | ---------------------------------------------- | -| `--event` | GitHub evento simulado para ejecutar el agente | -| `--token` | GitHub token de acceso personal | +| Opción | Descripción | +| ------------------------------------- | ---------------------------------------------- | +| {"--event"} | GitHub evento simulado para ejecutar el agente | +| {"--token"} | GitHub token de acceso personal | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Opciones -| Opción | Descripción | -| ----------- | --------------------------------------------------------------------------- | -| `--refresh` | Actualizar la caché de modelos desde models.dev | -| `--verbose` | Utilice una salida del modelo más detallada (incluye metadatos como costos) | +| Opción | Descripción | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--refresh"} | Actualizar la caché de modelos desde models.dev | +| {"--verbose"} | Utilice una salida del modelo más detallada (incluye metadatos como costos) | Utilice el indicador `--refresh` para actualizar la lista de modelos almacenados en caché. Esto es útil cuando se han agregado nuevos modelos a un proveedor y desea verlos en OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Opciones -| Opción | Corta | Descripción | -| ------------ | ----- | ----------------------------------------------------------------------------------- | -| `--command` | | El comando a ejecutar, use mensaje para args | -| `--continue` | `-c` | Continuar la última sesión | -| `--session` | `-s` | ID de sesión para continuar | -| `--fork` | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | -| `--share` | | Comparte la sesión | -| `--model` | `-m` | Modelo a utilizar en forma de proveedor/modelo | -| `--agent` | | Agente a utilizar | -| `--file` | `-f` | Archivo(s) para adjuntar al mensaje | -| `--format` | | Formato: predeterminado (formateado) o json (eventos JSON sin formato) | -| `--title` | | Título de la sesión (utiliza un mensaje truncado si no se proporciona ningún valor) | -| `--attach` | | Adjuntar a un servidor opencode en ejecución (por ejemplo, http://localhost:4096) | -| `--port` | | Puerto para el servidor local (el puerto predeterminado es aleatorio) | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------- | +| {"--command"} | | El comando a ejecutar, use mensaje para args | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--share"} | | Comparte la sesión | +| {"--model"} | `-m` | Modelo a utilizar en forma de proveedor/modelo | +| {"--agent"} | | Agente a utilizar | +| {"--file"} | `-f` | Archivo(s) para adjuntar al mensaje | +| {"--format"} | | Formato: predeterminado (formateado) o json (eventos JSON sin formato) | +| {"--title"} | | Título de la sesión (utiliza un mensaje truncado si no se proporciona ningún valor) | +| {"--attach"} | | Adjuntar a un servidor opencode en ejecución (por ejemplo, http://localhost:4096) | +| {"--password"} | `-p` | Contraseña de autenticación básica (predeterminada: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuario de autenticación básica (predeterminado: `OPENCODE_SERVER_USERNAME` u `opencode`) | +| {"--dir"} | | Directorio de ejecución, o ruta en el servidor remoto al adjuntar | +| {"--variant"} | | Variante del modelo (esfuerzo de razonamiento específico del proveedor) | +| {"--thinking"} | | Mostrar bloques de pensamiento | +| {"--port"} | | Puerto para el servidor local (el puerto predeterminado es aleatorio) | --- @@ -364,12 +373,12 @@ Esto inicia un servidor HTTP que proporciona acceso API a la funcionalidad openc #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------------------------------- | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | -| `--mdns` | Habilitar el descubrimiento de mDNS | -| `--cors` | Orígenes de navegador adicionales para permitir CORS | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------------------------------- | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | +| {"--mdns"} | Habilitar el descubrimiento de mDNS | +| {"--cors"} | Orígenes de navegador adicionales para permitir CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Opciones -| Opción | Corta | Descripción | -| ------------- | ----- | --------------------------------------- | -| `--max-count` | `-n` | Limitar a N sesiones más recientes | -| `--format` | | Formato de salida: tabla o json (tabla) | +| Opción | Corta | Descripción | +| ----------------------------------------- | ----- | --------------------------------------- | +| {"--max-count"} | `-n` | Limitar a N sesiones más recientes | +| {"--format"} | | Formato de salida: tabla o json (tabla) | --- @@ -410,12 +419,12 @@ opencode stats #### Opciones -| Opción | Descripción | -| ----------- | ------------------------------------------------------------------------------------------------------------------------ | -| `--days` | Mostrar estadísticas de los últimos N días (todo el tiempo) | -| `--tools` | Número de herramientas para mostrar (todas) | -| `--models` | Mostrar el desglose del uso del modelo (oculto de forma predeterminada). Pase un número para mostrar la parte superior N | -| `--project` | Filtrar por proyecto (todos los proyectos, cadena vacía: proyecto actual) | +| Opción | Descripción | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| {"--days"} | Mostrar estadísticas de los últimos N días (todo el tiempo) | +| {"--tools"} | Número de herramientas para mostrar (todas) | +| {"--models"} | Mostrar el desglose del uso del modelo (oculto de forma predeterminada). Pase un número para mostrar la parte superior N | +| {"--project"} | Filtrar por proyecto (todos los proyectos, cadena vacía: proyecto actual) | --- @@ -460,12 +469,12 @@ Esto inicia un servidor HTTP y abre un navegador web para acceder a OpenCode a t #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------------------------------- | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | -| `--mdns` | Habilitar el descubrimiento de mDNS | -| `--cors` | Orígenes de navegador adicionales para permitir CORS | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------------------------------- | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | +| {"--mdns"} | Habilitar el descubrimiento de mDNS | +| {"--cors"} | Orígenes de navegador adicionales para permitir CORS | --- @@ -481,11 +490,11 @@ Este comando inicia un servidor ACP que se comunica a través de stdin/stdout us #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------- | -| `--cwd` | Directorio de trabajo | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------- | +| {"--cwd"} | Directorio de trabajo | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | --- @@ -499,12 +508,12 @@ opencode uninstall #### Opciones -| Opción | Corta | Descripción | -| --------------- | ----- | ----------------------------------------- | -| `--keep-config` | `-c` | Mantener archivos de configuración | -| `--keep-data` | `-d` | Conservar datos de sesión e instantáneas | -| `--dry-run` | | Mostrar lo que se eliminaría sin eliminar | -| `--force` | `-f` | Saltar mensajes de confirmación | +| Opción | Corta | Descripción | +| ------------------------------------------- | ----- | ----------------------------------------- | +| {"--keep-config"} | `-c` | Mantener archivos de configuración | +| {"--keep-data"} | `-d` | Conservar datos de sesión e instantáneas | +| {"--dry-run"} | | Mostrar lo que se eliminaría sin eliminar | +| {"--force"} | `-f` | Saltar mensajes de confirmación | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Opciones -| Opción | Corta | Descripción | -| ---------- | ----- | ------------------------------------------------------------------- | -| `--method` | `-m` | El método de instalación que se utilizó; curl, npm, pnpm, bun, brew | +| Opción | Corta | Descripción | +| -------------------------------------- | ----- | ------------------------------------------------------------------- | +| {"--method"} | `-m` | El método de instalación que se utilizó; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI de OpenCode toma las siguientes banderas globales. -| Opción | Corta | Descripción | -| -------------- | ----- | -------------------------------------------- | -| `--help` | `-h` | Mostrar ayuda | -| `--version` | `-v` | Número de versión de impresión | -| `--print-logs` | | Imprimir registros en stderr | -| `--log-level` | | Nivel de registro (DEBUG, INFO, WARN, ERROR) | +| Opción | Corta | Descripción | +| ------------------------------------------ | ----- | -------------------------------------------- | +| {"--help"} | `-h` | Mostrar ayuda | +| {"--version"} | `-v` | Número de versión de impresión | +| {"--print-logs"} | | Imprimir registros en stderr | +| {"--log-level"} | | Nivel de registro (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/es/modes.mdx b/packages/web/src/content/docs/es/modes.mdx deleted file mode 100644 index dca900dff0..0000000000 --- a/packages/web/src/content/docs/es/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modos -description: Diferentes modos para diferentes casos de uso. ---- - -:::caution -Los modos ahora se configuran a través de la opción `agent` en la configuración opencode. El -La opción `mode` ahora está en desuso. [Más información](/docs/agents). -::: - -Los modos en opencode le permiten personalizar el comportamiento, las herramientas y las indicaciones para diferentes casos de uso. - -Viene con dos modos integrados: **construir** y **planificar**. Puedes personalizar -estos o configure el suyo propio a través de la configuración opencode. - -Puede cambiar entre modos durante una sesión o configurarlos en su archivo de configuración. - ---- - -## Integrados - -opencode viene con dos modos integrados. - ---- - -### Modo Build - -Build es el modo **predeterminado** con todas las herramientas habilitadas. Este es el modo estándar para el trabajo de desarrollo en el que necesita acceso completo a las operaciones de archivos y a los comandos del sistema. - ---- - -### Modo Plan - -Un modo restringido diseñado para la planificación y el análisis. En el modo de plan, las siguientes herramientas están deshabilitadas de forma predeterminada: - -- `write` - No se pueden crear archivos nuevos -- `edit` - No se pueden modificar archivos existentes, excepto los archivos ubicados en `.opencode/plans/*.md` para detallar el plan en sí. -- `patch` - No se pueden aplicar parches -- `bash` - No se pueden ejecutar comandos de shell - -Este modo es útil cuando desea que la IA analice código, sugiera cambios o cree planes sin realizar modificaciones reales en su base de código. - ---- - -## Cambiar de modo - -Puede cambiar entre modos durante una sesión usando la tecla _Tab_. O su combinación de teclas `switch_mode` configurada. - -Consulte también: [Formateadores](/docs/formatters) para obtener información sobre la configuración de formato de código. - ---- - -## Configuración - -Puede personalizar los modos integrados o crear los suyos propios mediante la configuración. Los modos se pueden configurar de dos maneras: - -### Configuración JSON - -Configure los modos en su archivo de configuración `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuración de Markdown - -También puede definir modos utilizando archivos de Markdown. Colócalos en: - -- Global: `~/.config/opencode/modes/` -- Proyecto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -El nombre del archivo de Markdown se convierte en el nombre del modo (por ejemplo, `review.md` crea un modo `review`). - -Veamos estas opciones de configuración en detalle. - ---- - -### Modelo - -Utilice la configuración `model` para anular el modelo predeterminado para este modo. Útil para utilizar diferentes modelos optimizados para diferentes tareas. Por ejemplo, un modelo más rápido de planificación, un modelo más capaz de implementación. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controle la aleatoriedad y la creatividad de las respuestas de la IA con la configuración `temperature`. Los valores más bajos hacen que las respuestas sean más centradas y deterministas, mientras que los valores más altos aumentan la creatividad y la variabilidad. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Los valores de temperatura suelen oscilar entre 0,0 y 1,0: - -- **0.0-0.2**: Respuestas muy enfocadas y deterministas, ideales para análisis y planificación de código. -- **0,3-0,5**: respuestas equilibradas con algo de creatividad, buenas para tareas de desarrollo general. -- **0.6-1.0**: respuestas más creativas y variadas, útiles para la lluvia de ideas y la exploración. - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Si no se especifica ninguna temperatura, opencode utiliza valores predeterminados específicos del modelo (normalmente 0 para la mayoría de los modelos, 0,55 para los modelos Qwen). - ---- - -### Indicación - -Especifique un archivo de aviso del sistema personalizado para este modo con la configuración `prompt`. El archivo de aviso debe contener instrucciones específicas para el propósito del modo. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Esta ruta es relativa a donde se encuentra el archivo de configuración. Entonces esto funciona para -tanto la configuración global opencode como la configuración específica del proyecto. - ---- - -### Herramientas - -Controle qué herramientas están disponibles en este modo con la configuración `tools`. Puede habilitar o deshabilitar herramientas específicas configurándolas en `true` o `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Si no se especifica ninguna herramienta, todas las herramientas están habilitadas de forma predeterminada. - ---- - -#### Herramientas disponibles - -Aquí están todas las herramientas que se pueden controlar a través del modo de configuración. - -| Herramienta | Descripción | -| ----------- | --------------------------------------- | -| `bash` | Ejecutar comandos de shell | -| `edit` | Modificar archivos existentes | -| `write` | Crear nuevos archivos | -| `read` | Leer el contenido del archivo | -| `grep` | Buscar contenido del archivo | -| `glob` | Buscar archivos por patrón | -| `patch` | Aplicar parches a archivos | -| `todowrite` | Administrar listas de tareas pendientes | -| `webfetch` | Obtener contenido web | - ---- - -## Modos personalizados - -Puede crear sus propios modos personalizados agregándolos a la configuración. A continuación se muestran ejemplos que utilizan ambos enfoques: - -### Usando la configuración JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando archivos de Markdown - -Cree archivos de modo en `.opencode/modes/` para modos específicos del proyecto o `~/.config/opencode/modes/` para modos globales: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casos de uso - -A continuación se muestran algunos casos de uso comunes para diferentes modos. - -- **Modo Build**: trabajo de desarrollo completo con todas las herramientas habilitadas -- **Modo Plan**: Análisis y planificación sin realizar cambios -- **Modo Review**: revisión de código con acceso de solo lectura más herramientas de documentación -- **Modo Debug**: centrado en la investigación con bash y herramientas de lectura habilitadas -- **Modo Docs**: escritura de documentacion con operaciones de archivos pero sin comandos del sistema - -También puede encontrar que diferentes modelos son buenos para diferentes casos de uso. diff --git a/packages/web/src/content/docs/es/providers.mdx b/packages/web/src/content/docs/es/providers.mdx index b44ce9ee99..11489609bc 100644 --- a/packages/web/src/content/docs/es/providers.mdx +++ b/packages/web/src/content/docs/es/providers.mdx @@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API. +1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API. -2. Ejecute el comando `/connect` y busque **Firmware**. +2. Ejecute el comando `/connect` y busque **FrogBot**. ```txt /connect ``` -3. Ingrese su clave de firmware API. +3. Ingrese su clave de frogbot API. ```txt ┌ API key diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index dbee49dca6..58b63fa348 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -3,7 +3,7 @@ title: Formatters description: OpenCode uses language specific formatters. --- -OpenCode automatically formats files after they are written or edited using language-specific formatters. This ensures that the code that is generated follows the code styles of your project. +OpenCode can format files after they are written or edited using language-specific formatters. Formatters are disabled by default; enable them in your config before OpenCode will run them. --- @@ -40,25 +40,36 @@ OpenCode comes with several built-in formatters for popular languages and framew | uv | .py, .pyi | `uv` command available | | zig | .zig, .zon | `zig` command available | -So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. +When formatters are enabled, OpenCode will use `prettier` for matching files if your project has `prettier` in `package.json`. --- ## How it works -When OpenCode writes or edits a file, it: +When OpenCode writes or edits a file and formatters are enabled, it: 1. Checks the file extension against all enabled formatters. 2. Runs the appropriate formatter command on the file. -3. Applies the formatting changes automatically. +3. Applies the formatting changes. -This process happens in the background, ensuring your code styles are maintained without any manual steps. +This process happens in the background for enabled formatters. --- ## Configure -You can customize formatters through the `formatter` section in your OpenCode config. +You can enable and customize formatters through the `formatter` section in your OpenCode config. + +To enable all built-in formatters, set `formatter` to `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom formatters. ```json title="opencode.json" { @@ -69,12 +80,12 @@ You can customize formatters through the `formatter` section in your OpenCode co Each formatter configuration supports the following: -| Property | Type | Description | -| ------------- | -------- | ------------------------------------------------------- | -| `disabled` | boolean | Set this to `true` to disable the formatter | -| `command` | string[] | The command to run for formatting | -| `environment` | object | Environment variables to set when running the formatter | -| `extensions` | string[] | File extensions this formatter should handle | +| Property | Type | Description | +| ------------- | -------- | ------------------------------------------------------------------------------------------ | +| `disabled` | boolean | Set this to `true` to disable the formatter | +| `command` | string[] | The command to run for formatting. Required for custom formatters; optional for built-ins. | +| `environment` | object | Environment variables to set when running the formatter | +| `extensions` | string[] | File extensions this formatter should handle | Let's look at some examples. @@ -82,7 +93,7 @@ Let's look at some examples. ### Disabling formatters -To disable **all** formatters globally, set `formatter` to `false`: +If `formatter` is omitted, all formatters are disabled. To disable all formatters after another config enabled them, set `formatter` to `false`: ```json title="opencode.json" {3} { @@ -108,7 +119,7 @@ To disable a **specific** formatter, set `disabled` to `true`: ### Custom formatters -You can override the built-in formatters or add new ones by specifying the command, environment variables, and file extensions: +You can configure built-in formatters with options like `environment` or `extensions`. To add a custom formatter, specify a `command` and `extensions`: ```json title="opencode.json" {4-14} { diff --git a/packages/web/src/content/docs/fr/cli.mdx b/packages/web/src/content/docs/fr/cli.mdx index cffa748ad2..c5455654a6 100644 --- a/packages/web/src/content/docs/fr/cli.mdx +++ b/packages/web/src/content/docs/fr/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Options -| Option | Court | Description | -| ------------ | ----- | ----------------------------------------------------------------------------- | -| `--continue` | `-c` | Continuer la dernière session | -| `--session` | `-s` | ID de session pour continuer | -| `--fork` | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | -| `--prompt` | | Prompt à utiliser | -| `--model` | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | -| `--agent` | | Agent à utiliser | -| `--port` | | Port d'écoute | -| `--hostname` | | Nom d'hôte d'écoute | +| Option | Court | Description | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | +| {"--prompt"} | | Prompt à utiliser | +| {"--model"} | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | +| {"--agent"} | | Agent à utiliser | +| {"--port"} | | Port d'écoute | +| {"--hostname"} | | Nom d'hôte d'écoute | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Options -| Option | Court | Description | -| ----------- | ----- | ---------------------------------------------- | -| `--dir` | | Répertoire de travail dans lequel démarrer TUI | -| `--session` | `-s` | ID de session pour continuer | +| Option | Court | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Répertoire de travail dans lequel démarrer TUI | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Dupliquer la session lors de la reprise (à utiliser avec `--continue` ou `--session`) | +| {"--password"} | `-p` | Mot de passe d'authentification de base (par défaut `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nom d'utilisateur d'authentification de base (par défaut `OPENCODE_SERVER_USERNAME` ou `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Options -| Option | Description | -| --------- | ---------------------------------------------------- | -| `--event` | Événement simulé GitHub pour lequel exécuter l'agent | -| `--token` | Jeton d'accès personnel GitHub | +| Option | Description | +| ------------------------------------- | ---------------------------------------------------- | +| {"--event"} | Événement simulé GitHub pour lequel exécuter l'agent | +| {"--token"} | Jeton d'accès personnel GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Options -| Option | Description | -| ----------- | ------------------------------------------------------------------------------------------ | -| `--refresh` | Actualisez le cache des modèles à partir de models.dev | -| `--verbose` | Utiliser une sortie de modèle plus détaillée (inclut des métadonnées telles que les coûts) | +| Option | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| {"--refresh"} | Actualisez le cache des modèles à partir de models.dev | +| {"--verbose"} | Utiliser une sortie de modèle plus détaillée (inclut des métadonnées telles que les coûts) | Utilisez l'option `--refresh` pour mettre à jour la liste des modèles mis en cache. Ceci est utile lorsque de nouveaux modèles ont été ajoutés à un fournisseur et que vous souhaitez les voir dans OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Options -| Option | Court | Description | -| ------------ | ----- | ---------------------------------------------------------------------------------------------- | -| `--command` | | La commande à exécuter, utilisez le message pour les arguments | -| `--continue` | `-c` | Continuer la dernière session | -| `--session` | `-s` | ID de session pour continuer | -| `--fork` | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | -| `--share` | | Partager la session | -| `--model` | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | -| `--agent` | | Agent à utiliser | -| `--file` | `-f` | Fichier(s) à joindre au message | -| `--format` | | Format : par défaut (formaté) ou json (événements JSON bruts) | -| `--title` | | Titre de la session (utilise un prompt tronqué si aucune valeur n'est fournie) | -| `--attach` | | Connectez-vous à un serveur opencode en cours d'exécution (par exemple, http://localhost:4096) | -| `--port` | | Port du serveur local (port aléatoire par défaut) | +| Option | Court | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------------------------------- | +| {"--command"} | | La commande à exécuter, utilisez le message pour les arguments | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | +| {"--share"} | | Partager la session | +| {"--model"} | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | +| {"--agent"} | | Agent à utiliser | +| {"--file"} | `-f` | Fichier(s) à joindre au message | +| {"--format"} | | Format : par défaut (formaté) ou json (événements JSON bruts) | +| {"--title"} | | Titre de la session (utilise un prompt tronqué si aucune valeur n'est fournie) | +| {"--attach"} | | Connectez-vous à un serveur opencode en cours d'exécution (par exemple, http://localhost:4096) | +| {"--password"} | `-p` | Mot de passe d'authentification de base (par défaut `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nom d'utilisateur d'authentification de base (par défaut `OPENCODE_SERVER_USERNAME` ou `opencode`) | +| {"--dir"} | | Répertoire d'exécution, ou chemin sur le serveur distant lors de l'attachement | +| {"--variant"} | | Variante du modèle (effort de raisonnement spécifique au fournisseur) | +| {"--thinking"} | | Afficher les blocs de réflexion | +| {"--port"} | | Port du serveur local (port aléatoire par défaut) | --- @@ -364,12 +373,12 @@ Cela démarre un serveur HTTP qui fournit un accès API aux fonctionnalités d'O #### Options -| Option | Description | -| ------------ | ---------------------------------------------------------- | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | -| `--mdns` | Activer la découverte mDNS | -| `--cors` | Origines de navigateur supplémentaires pour autoriser CORS | +| Option | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | +| {"--mdns"} | Activer la découverte mDNS | +| {"--cors"} | Origines de navigateur supplémentaires pour autoriser CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Options -| Option | Court | Description | -| ------------- | ----- | -------------------------------------------- | -| `--max-count` | `-n` | Limiter aux N sessions les plus récentes | -| `--format` | | Format de sortie : tableau ou json (tableau) | +| Option | Court | Description | +| ----------------------------------------- | ----- | -------------------------------------------- | +| {"--max-count"} | `-n` | Limiter aux N sessions les plus récentes | +| {"--format"} | | Format de sortie : tableau ou json (tableau) | --- @@ -410,12 +419,12 @@ opencode stats #### Options -| Option | Description | -| ----------- | --------------------------------------------------------------------------------------------------------------------- | -| `--days` | Afficher les statistiques des N derniers jours (depuis le début) | -| `--tools` | Nombre d'outils à afficher (tous) | -| `--models` | Afficher la répartition de l'utilisation du modèle (masqué par défaut). Passez un numéro pour afficher les N premiers | -| `--project` | Filtrer par projet (tous les projets, chaîne vide : projet actuel) | +| Option | Description | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| {"--days"} | Afficher les statistiques des N derniers jours (depuis le début) | +| {"--tools"} | Nombre d'outils à afficher (tous) | +| {"--models"} | Afficher la répartition de l'utilisation du modèle (masqué par défaut). Passez un numéro pour afficher les N premiers | +| {"--project"} | Filtrer par projet (tous les projets, chaîne vide : projet actuel) | --- @@ -460,12 +469,12 @@ Cela démarre un serveur HTTP et ouvre un navigateur Web pour accéder à OpenCo #### Options -| Option | Description | -| ------------ | ---------------------------------------------------------- | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | -| `--mdns` | Activer la découverte mDNS | -| `--cors` | Origines de navigateur supplémentaires pour autoriser CORS | +| Option | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | +| {"--mdns"} | Activer la découverte mDNS | +| {"--cors"} | Origines de navigateur supplémentaires pour autoriser CORS | --- @@ -481,11 +490,11 @@ Cette commande démarre un serveur ACP qui communique via stdin/stdout en utilis #### Options -| Option | Description | -| ------------ | --------------------- | -| `--cwd` | Répertoire de travail | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | +| Option | Description | +| ---------------------------------------- | --------------------- | +| {"--cwd"} | Répertoire de travail | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | --- @@ -499,12 +508,12 @@ opencode uninstall #### Options -| Option | Court | Description | -| --------------- | ----- | --------------------------------------------------- | -| `--keep-config` | `-c` | Conserver les fichiers de configuration | -| `--keep-data` | `-d` | Conserver les données de session et les instantanés | -| `--dry-run` | | Afficher ce qui serait supprimé sans supprimer | -| `--force` | `-f` | Ignorer les invites de confirmation | +| Option | Court | Description | +| ------------------------------------------- | ----- | --------------------------------------------------- | +| {"--keep-config"} | `-c` | Conserver les fichiers de configuration | +| {"--keep-data"} | `-d` | Conserver les données de session et les instantanés | +| {"--dry-run"} | | Afficher ce qui serait supprimé sans supprimer | +| {"--force"} | `-f` | Ignorer les invites de confirmation | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Options -| Option | Court | Description | -| ---------- | ----- | --------------------------------------------------------------- | -| `--method` | `-m` | La méthode d'installation utilisée ; curl, npm, pnpm, bun, brew | +| Option | Court | Description | +| -------------------------------------- | ----- | --------------------------------------------------------------- | +| {"--method"} | `-m` | La méthode d'installation utilisée ; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI opencode prend les flags globaux suivants. -| Option | Court | Description | -| -------------- | ----- | ---------------------------------------- | -| `--help` | `-h` | Afficher l'aide | -| `--version` | `-v` | Afficher le numéro de version | -| `--print-logs` | | Afficher les logs sur stderr | -| `--log-level` | | Niveau de log (DEBUG, INFO, WARN, ERROR) | +| Option | Court | Description | +| ------------------------------------------ | ----- | ---------------------------------------- | +| {"--help"} | `-h` | Afficher l'aide | +| {"--version"} | `-v` | Afficher le numéro de version | +| {"--print-logs"} | | Afficher les logs sur stderr | +| {"--log-level"} | | Niveau de log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/fr/modes.mdx b/packages/web/src/content/docs/fr/modes.mdx deleted file mode 100644 index 6985dbd57d..0000000000 --- a/packages/web/src/content/docs/fr/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: Modes -description: Différents modes pour différents cas d'utilisation. ---- - -:::caution -Les modes sont désormais configurés via l'option `agent` dans la configuration opencode. -L’option `mode` est désormais obsolète. [En savoir plus](/docs/agents). -::: - -Les modes dans opencode vous permettent de personnaliser le comportement, les outils et les prompts pour différents cas d'utilisation. - -Il est livré avec deux modes intégrés : **build** et **plan**. Vous pouvez personnaliser ceux-ci ou configurez les vôtres via la configuration opencode. - -Vous pouvez basculer entre les modes au cours d'une session ou les configurer dans votre fichier de configuration. - ---- - -## Modes intégrés - -opencode est livré avec deux modes intégrés. - ---- - -### Build - -Build est le mode **par défaut** avec tous les outils activés. Il s'agit du mode standard pour le travail de développement dans lequel vous avez besoin d'un accès complet aux opérations sur les fichiers et aux commandes système. - ---- - -### Plan - -Un mode restreint conçu pour la planification et l’analyse. En mode plan, les outils suivants sont désactivés par défaut : - -- `write` - Impossible de créer de nouveaux fichiers -- `edit` - Impossible de modifier les fichiers existants, à l'exception des fichiers situés à `.opencode/plans/*.md` pour détailler le plan lui-même -- `patch` - Impossible d'appliquer les correctifs -- `bash` - Impossible d'exécuter les commandes shell - -Ce mode est utile lorsque vous souhaitez que l'IA analyse le code, suggère des modifications ou crée des plans sans apporter de modifications réelles à votre base de code. - ---- - -## Changement de mode - -Vous pouvez basculer entre les modes au cours d'une session à l'aide de la touche _Tab_. Ou votre raccourci clavier `switch_mode` configuré. - -Voir également : [Formatters](/docs/formatters) pour plus d'informations sur la configuration du formatage du code. - ---- - -## Configuration - -Vous pouvez personnaliser les modes intégrés ou créer les vôtres via la configuration. Les modes peuvent être configurés de deux manières : - -### Configuration JSON - -Configurez les modes dans votre fichier de configuration `opencode.json` : - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuration Markdown - -Vous pouvez également définir des modes à l'aide de fichiers markdown. Placez-les dans : - -- Global : `~/.config/opencode/modes/` -- Projet : `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Le nom du fichier markdown devient le nom du mode (par exemple, `review.md` crée un mode `review`). - -Examinons ces options de configuration en détail. - ---- - -### Modèle - -Utilisez la configuration `model` pour remplacer le modèle par défaut pour ce mode. Utile pour utiliser différents modèles optimisés pour différentes tâches. Par exemple, un modèle de planification plus rapide, un modèle de mise en œuvre plus performant. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Température - -Contrôlez le caractère aléatoire et la créativité des réponses de l'IA avec la configuration `temperature`. Des valeurs faibles rendent les réponses plus ciblées et déterministes, tandis que des valeurs plus élevées augmentent la créativité et la variabilité. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Les valeurs de température varient généralement de 0,0 à 1,0 : - -- **0,0-0,2** : réponses très ciblées et déterministes, idéales pour l'analyse et la planification du code -- **0,3-0,5** : réponses équilibrées avec une certaine créativité, idéales pour les tâches de développement générales -- **0,6-1,0** : réponses plus créatives et variées, utiles pour le brainstorming et l'exploration - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Si aucune température n'est spécifiée, opencode utilise les valeurs par défaut spécifiques au modèle (généralement 0 pour la plupart des modèles, 0,55 pour les modèles Qwen). - ---- - -### Invite - -Spécifiez un fichier de prompt système personnalisé pour ce mode avec la configuration `prompt`. Le fichier de prompt doit contenir des instructions spécifiques à l'objectif du mode. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ce chemin est relatif à l'emplacement du fichier de configuration. Donc ça marche pour à la fois la configuration globale opencode et la configuration spécifique au projet. - ---- - -### Outils - -Contrôlez quels outils sont disponibles dans ce mode avec la configuration `tools`. Vous pouvez activer ou désactiver des outils spécifiques en les définissant sur `true` ou `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Si aucun outil n'est spécifié, tous les outils sont activés par défaut. - ---- - -#### Outils disponibles - -Voici tous les outils pouvant être contrôlés via le mode config. - -| Outil | Description | -| ----------- | ------------------------------------- | -| `bash` | Exécuter des commandes shell | -| `edit` | Modifier des fichiers existants | -| `write` | Créer de nouveaux fichiers | -| `read` | Lire le contenu du fichier | -| `grep` | Rechercher le contenu du fichier | -| `glob` | Rechercher des fichiers par modèle | -| `patch` | Appliquer des correctifs aux fichiers | -| `todowrite` | Gérer les listes de tâches | -| `webfetch` | Récupérer du contenu Web | - ---- - -## Modes personnalisés - -Vous pouvez créer vos propres modes personnalisés en les ajoutant à la configuration. Voici des exemples utilisant les deux approches : - -### Utilisation de la configuration JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Utiliser des fichiers Markdown - -Créez des fichiers de mode dans `.opencode/modes/` pour les modes spécifiques au projet ou `~/.config/opencode/modes/` pour les modes globaux : - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Cas d'utilisation - -Voici quelques cas d’utilisation courants pour différents modes. - -- **Mode Build** : travail de développement complet avec tous les outils activés -- **Mode Plan** : Analyse et planification sans apporter de modifications -- **Mode Review** : révision du code avec accès en lecture seule et outils de documentation -- **Mode Debug** : axé sur l'investigation avec les outils bash et read activés -- **Mode Docs** : écriture de documentation avec des opérations sur les fichiers mais pas de commandes système - -Vous constaterez peut-être également que différents modèles conviennent à différents cas d’utilisation. diff --git a/packages/web/src/content/docs/fr/providers.mdx b/packages/web/src/content/docs/fr/providers.mdx index 6a902ab02f..90bdb1fbc3 100644 --- a/packages/web/src/content/docs/fr/providers.mdx +++ b/packages/web/src/content/docs/fr/providers.mdx @@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode. --- -### Firmware +### FrogBot -1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API. +1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API. -2. Exécutez la commande `/connect` et recherchez **Firmware**. +2. Exécutez la commande `/connect` et recherchez **FrogBot**. ```txt /connect diff --git a/packages/web/src/content/docs/it/cli.mdx b/packages/web/src/content/docs/it/cli.mdx index 973eb0d988..6fd3d56769 100644 --- a/packages/web/src/content/docs/it/cli.mdx +++ b/packages/web/src/content/docs/it/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flag -| Flag | Breve | Descrizione | -| ------------ | ----- | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Continua l'ultima sessione | -| `--session` | `-s` | ID sessione da continuare | -| `--fork` | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | -| `--prompt` | | Prompt da usare | -| `--model` | `-m` | Modello nel formato provider/model | -| `--agent` | | Agente da usare | -| `--port` | | Porta su cui mettersi in ascolto | -| `--hostname` | | Hostname su cui mettersi in ascolto | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | +| {"--prompt"} | | Prompt da usare | +| {"--model"} | `-m` | Modello nel formato provider/model | +| {"--agent"} | | Agente da usare | +| {"--port"} | | Porta su cui mettersi in ascolto | +| {"--hostname"} | | Hostname su cui mettersi in ascolto | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flag -| Flag | Breve | Descrizione | -| ----------- | ----- | --------------------------------------- | -| `--dir` | | Working directory in cui avviare la TUI | -| `--session` | `-s` | ID sessione da continuare | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------- | +| {"--dir"} | | Working directory in cui avviare la TUI | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Crea un fork della sessione durante la continuazione (usa con `--continue` o `--session`) | +| {"--password"} | `-p` | Password per l'autenticazione di base (predefinita: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nome utente per l'autenticazione di base (predefinito: `OPENCODE_SERVER_USERNAME` o `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flag -| Flag | Descrizione | -| --------- | -------------------------------------------- | -| `--event` | Evento GitHub mock per cui eseguire l'agente | -| `--token` | GitHub personal access token | +| Flag | Descrizione | +| ------------------------------------- | -------------------------------------------- | +| {"--event"} | Evento GitHub mock per cui eseguire l'agente | +| {"--token"} | GitHub personal access token | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flag -| Flag | Descrizione | -| ----------- | -------------------------------------------------- | -| `--refresh` | Aggiorna la cache modelli da models.dev | -| `--verbose` | Output più verboso (include metadati come i costi) | +| Flag | Descrizione | +| --------------------------------------- | -------------------------------------------------- | +| {"--refresh"} | Aggiorna la cache modelli da models.dev | +| {"--verbose"} | Output più verboso (include metadati come i costi) | Usa `--refresh` per aggiornare l'elenco modelli in cache. È utile quando nuovi modelli vengono aggiunti a un provider e vuoi vederli in OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flag -| Flag | Breve | Descrizione | -| ------------ | ----- | ------------------------------------------------------------------------ | -| `--command` | | Il comando da eseguire; usa message per gli argomenti | -| `--continue` | `-c` | Continua l'ultima sessione | -| `--session` | `-s` | ID sessione da continuare | -| `--fork` | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | -| `--share` | | Condividi la sessione | -| `--model` | `-m` | Modello nel formato provider/model | -| `--agent` | | Agente da usare | -| `--file` | `-f` | File da allegare al messaggio | -| `--format` | | Formato: default (formattato) o json (eventi JSON grezzi) | -| `--title` | | Titolo sessione (usa prompt troncato se non viene fornito un valore) | -| `--attach` | | Attach a un server opencode in esecuzione (es. http://localhost:4096) | -| `--port` | | Porta per il server locale (di default una porta casuale) | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------- | +| {"--command"} | | Il comando da eseguire; usa message per gli argomenti | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | +| {"--share"} | | Condividi la sessione | +| {"--model"} | `-m` | Modello nel formato provider/model | +| {"--agent"} | | Agente da usare | +| {"--file"} | `-f` | File da allegare al messaggio | +| {"--format"} | | Formato: default (formattato) o json (eventi JSON grezzi) | +| {"--title"} | | Titolo sessione (usa prompt troncato se non viene fornito un valore) | +| {"--attach"} | | Attach a un server opencode in esecuzione (es. http://localhost:4096) | +| {"--password"} | `-p` | Password per l'autenticazione di base (predefinita: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nome utente per l'autenticazione di base (predefinito: `OPENCODE_SERVER_USERNAME` o `opencode`) | +| {"--dir"} | | Directory di esecuzione, o percorso sul server remoto durante il collegamento | +| {"--variant"} | | Variante del modello (sforzo di ragionamento specifico del provider) | +| {"--thinking"} | | Mostra blocchi di pensiero | +| {"--port"} | | Porta per il server locale (di default una porta casuale) | --- @@ -364,12 +373,12 @@ Avvia un server HTTP che espone accesso API alle funzionalità di opencode senza #### Flag -| Flag | Descrizione | -| ------------ | ---------------------------------------------- | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | -| `--mdns` | Abilita discovery mDNS | -| `--cors` | Origin browser addizionali per consentire CORS | +| Flag | Descrizione | +| ---------------------------------------- | ---------------------------------------------- | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | +| {"--mdns"} | Abilita discovery mDNS | +| {"--cors"} | Origin browser addizionali per consentire CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flag -| Flag | Breve | Descrizione | -| ------------- | ----- | ------------------------------------ | -| `--max-count` | `-n` | Limita alle N sessioni più recenti | -| `--format` | | Formato output: table o json (table) | +| Flag | Breve | Descrizione | +| ----------------------------------------- | ----- | ------------------------------------ | +| {"--max-count"} | `-n` | Limita alle N sessioni più recenti | +| {"--format"} | | Formato output: table o json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### Flag -| Flag | Descrizione | -| ----------- | ------------------------------------------------------------------------------------- | -| `--days` | Mostra statistiche per gli ultimi N giorni (all time) | -| `--tools` | Numero di strumenti da mostrare (all) | -| `--models` | Mostra breakdown di utilizzo modelli (nascosto di default). Passa un numero per top N | -| `--project` | Filtra per progetto (tutti i progetti; stringa vuota: progetto corrente) | +| Flag | Descrizione | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--days"} | Mostra statistiche per gli ultimi N giorni (all time) | +| {"--tools"} | Numero di strumenti da mostrare (all) | +| {"--models"} | Mostra breakdown di utilizzo modelli (nascosto di default). Passa un numero per top N | +| {"--project"} | Filtra per progetto (tutti i progetti; stringa vuota: progetto corrente) | --- @@ -460,12 +469,12 @@ Avvia un server HTTP e apre un browser per accedere a OpenCode tramite interfacc #### Flag -| Flag | Descrizione | -| ------------ | ---------------------------------------------- | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | -| `--mdns` | Abilita discovery mDNS | -| `--cors` | Origin browser addizionali per consentire CORS | +| Flag | Descrizione | +| ---------------------------------------- | ---------------------------------------------- | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | +| {"--mdns"} | Abilita discovery mDNS | +| {"--cors"} | Origin browser addizionali per consentire CORS | --- @@ -481,11 +490,11 @@ Questo comando avvia un server ACP che comunica via stdin/stdout usando nd-JSON. #### Flag -| Flag | Descrizione | -| ------------ | ----------------------------------- | -| `--cwd` | Directory di lavoro | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | +| Flag | Descrizione | +| ---------------------------------------- | ----------------------------------- | +| {"--cwd"} | Directory di lavoro | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flag -| Flag | Breve | Descrizione | -| --------------- | ----- | -------------------------------------------- | -| `--keep-config` | `-c` | Mantieni i file di configurazione | -| `--keep-data` | `-d` | Mantieni dati di sessione e snapshot | -| `--dry-run` | | Mostra cosa verrebbe rimosso senza rimuovere | -| `--force` | `-f` | Salta le richieste di conferma | +| Flag | Breve | Descrizione | +| ------------------------------------------- | ----- | -------------------------------------------- | +| {"--keep-config"} | `-c` | Mantieni i file di configurazione | +| {"--keep-data"} | `-d` | Mantieni dati di sessione e snapshot | +| {"--dry-run"} | | Mostra cosa verrebbe rimosso senza rimuovere | +| {"--force"} | `-f` | Salta le richieste di conferma | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flag -| Flag | Breve | Descrizione | -| ---------- | ----- | --------------------------------------------------------- | -| `--method` | `-m` | Metodo di installazione usato: curl, npm, pnpm, bun, brew | +| Flag | Breve | Descrizione | +| -------------------------------------- | ----- | --------------------------------------------------------- | +| {"--method"} | `-m` | Metodo di installazione usato: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI di opencode accetta i seguenti flag globali. -| Flag | Breve | Descrizione | -| -------------- | ----- | -------------------------------------- | -| `--help` | `-h` | Mostra l'help | -| `--version` | `-v` | Stampa il numero di versione | -| `--print-logs` | | Stampa i log su stderr | -| `--log-level` | | Livello log (DEBUG, INFO, WARN, ERROR) | +| Flag | Breve | Descrizione | +| ------------------------------------------ | ----- | -------------------------------------- | +| {"--help"} | `-h` | Mostra l'help | +| {"--version"} | `-v` | Stampa il numero di versione | +| {"--print-logs"} | | Stampa i log su stderr | +| {"--log-level"} | | Livello log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/it/modes.mdx b/packages/web/src/content/docs/it/modes.mdx deleted file mode 100644 index 052808f7ac..0000000000 --- a/packages/web/src/content/docs/it/modes.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Modalità -description: Modalita diverse per casi d'uso diversi. ---- - -:::caution -Le modalita ora si configurano tramite l'opzione `agent` nella configurazione di opencode. L'opzione -`mode` e ora deprecata. [Scopri di piu](/docs/agents). -::: - -Le modalita in opencode ti permettono di personalizzare comportamento, strumenti e prompt per casi d'uso diversi. - -Include due modalita integrate: **build** e **plan**. Puoi personalizzarle oppure configurarne di tue tramite la configurazione di opencode. - -Puoi passare da una modalita all'altra durante una sessione oppure configurarle nel file di configurazione. - ---- - -## Integrate - -opencode include due modalita integrate. - ---- - -### Build - -Build e la modalita **predefinita** con tutti gli strumenti abilitati. E la modalita standard per il lavoro di sviluppo quando ti serve accesso completo alle operazioni sui file e ai comandi di sistema. - ---- - -### Plan - -Una modalita limitata pensata per pianificazione e analisi. In modalita plan, i seguenti strumenti sono disabilitati per impostazione predefinita: - -- `write` - Non puo creare nuovi file -- `edit` - Non puo modificare file esistenti, tranne i file in `.opencode/plans/*.md` per dettagliare il piano -- `patch` - Non puo applicare patch -- `bash` - Non puo eseguire comandi shell - -Questa modalita e utile quando vuoi che l'AI analizzi il codice, suggerisca modifiche o crei piani senza apportare modifiche effettive alla codebase. - ---- - -## Cambiare modalità - -Puoi cambiare modalita durante una sessione usando il tasto _Tab_. In alternativa, puoi usare il keybind `switch_mode` che hai configurato. - -Vedi anche: [Formatter](/docs/formatters) per informazioni sulla configurazione della formattazione del codice. - ---- - -## Configurazione - -Puoi personalizzare le modalita integrate o crearne di tue tramite configurazione. Le modalita si possono configurare in due modi: - -### Configurazione JSON - -Configura le modalita nel file di configurazione `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configurazione Markdown - -Puoi anche definire modalita usando file markdown. Mettili in: - -- Globale: `~/.config/opencode/modes/` -- Progetto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Il nome del file markdown diventa il nome della modalita (ad es. `review.md` crea una modalita `review`). - -Vediamo queste opzioni di configurazione nel dettaglio. - ---- - -### Modello - -Usa la configurazione `model` per sovrascrivere il modello predefinito per questa modalita. E utile per usare modelli diversi ottimizzati per task diversi: per esempio, un modello piu veloce per la pianificazione e uno piu capace per l'implementazione. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controlla casualita e creativita delle risposte dell'AI con la configurazione `temperature`. Valori piu bassi rendono le risposte piu focalizzate e deterministiche, mentre valori piu alti aumentano creativita e variabilita. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -I valori di temperatura di solito vanno da 0.0 a 1.0: - -- **0.0-0.2**: risposte molto focalizzate e deterministiche, ideali per analisi del codice e pianificazione -- **0.3-0.5**: risposte bilanciate con un po' di creativita, buone per task di sviluppo generici -- **0.6-1.0**: risposte piu creative e variabili, utili per brainstorming ed esplorazione - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Se non viene specificata alcuna temperatura, opencode usa i default specifici del modello (tipicamente 0 per la maggior parte dei modelli, 0.55 per i modelli Qwen). - ---- - -### Prompt - -Specifica un file di prompt di sistema personalizzato per questa modalita con la configurazione `prompt`. Il file dovrebbe contenere istruzioni specifiche per lo scopo della modalita. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Questo percorso e relativo a dove si trova il file di configurazione. Quindi funziona -sia per la configurazione globale di opencode sia per quella specifica del progetto. - ---- - -### Strumenti - -Controlla quali strumenti sono disponibili in questa modalita con la configurazione `tools`. Puoi abilitare o disabilitare strumenti specifici impostandoli a `true` o `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Se non specifichi gli strumenti, tutti gli strumenti sono abilitati per impostazione predefinita. - ---- - -#### Strumenti disponibili - -Ecco tutti gli strumenti che possono essere controllati tramite la configurazione della modalita. - -| Strumento | Descrizione | -| ----------- | ---------------------------- | -| `bash` | Esegue comandi shell | -| `edit` | Modifica file esistenti | -| `write` | Crea nuovi file | -| `read` | Legge contenuti dei file | -| `grep` | Cerca nei contenuti dei file | -| `glob` | Trova file per pattern | -| `patch` | Applica patch ai file | -| `todowrite` | Gestisce liste todo | -| `webfetch` | Recupera contenuti web | - ---- - -## Modalità personalizzate - -Puoi creare modalita personalizzate aggiungendole alla configurazione. Ecco esempi con entrambi gli approcci: - -### Usando la configurazione JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando file markdown - -Crea file di modalita in `.opencode/modes/` per modalita specifiche del progetto oppure in `~/.config/opencode/modes/` per modalita globali: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casi d'uso - -Ecco alcuni casi d'uso comuni per le diverse modalita. - -- **Modalita Build**: sviluppo completo con tutti gli strumenti abilitati -- **Modalita Plan**: analisi e pianificazione senza apportare modifiche -- **Modalita Review**: code review con accesso in sola lettura piu strumenti di documentazione -- **Modalita Debug**: focalizzata sull'investigazione con strumenti bash e read abilitati -- **Modalita Docs**: scrittura di documentazione con operazioni sui file ma senza comandi di sistema - -Potresti anche trovare che modelli diversi funzionano meglio per casi d'uso diversi. diff --git a/packages/web/src/content/docs/it/providers.mdx b/packages/web/src/content/docs/it/providers.mdx index 96da8c4df1..f2d195d721 100644 --- a/packages/web/src/content/docs/it/providers.mdx +++ b/packages/web/src/content/docs/it/providers.mdx @@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API. +1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API. -2. Esegui il comando `/connect` e cerca **Firmware**. +2. Esegui il comando `/connect` e cerca **FrogBot**. ```txt /connect ``` -3. Inserisci la tua chiave API di Firmware. +3. Inserisci la tua chiave API di FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ja/cli.mdx b/packages/web/src/content/docs/ja/cli.mdx index 82a8852ea5..120803627a 100644 --- a/packages/web/src/content/docs/ja/cli.mdx +++ b/packages/web/src/content/docs/ja/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### フラグ -| フラグ | ショート | 説明 | -| ------------ | ----------- | ---------------------------------------------------------- | -| `--continue` | `-c` | 最後のセッションを続行 | -| `--session` | | 続行時にセッションをフォーク (`-s` または `--fork` と併用) | -| `--continue` | `--session` | 続行するセッション ID | -| `--prompt` | | 使用のプロンプト | -| `--model` | `-m` | プロバイダー/モデルの形式で使用するモデル | -| `--agent` | | 使用するエージェント | -| `--port` | | リッスンするポート | -| `--hostname` | | リッスンするホスト名 | +| フラグ | ショート | 説明 | +| ---------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | | 続行時にセッションをフォーク (`-s` または `--fork` と併用) | +| {"--continue"} | {"--session"} | 続行するセッション ID | +| {"--prompt"} | | 使用のプロンプト | +| {"--model"} | `-m` | プロバイダー/モデルの形式で使用するモデル | +| {"--agent"} | | 使用するエージェント | +| {"--port"} | | リッスンするポート | +| {"--hostname"} | | リッスンするホスト名 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### フラグ -| フラグ | ショート | 説明 | -| ----------- | -------- | ------------------------------ | -| `--dir` | | TUI を開始する作業ディレクトリ | -| `--session` | `-s` | 続行するセッション ID | +| フラグ | ショート | 説明 | +| ---------------------------------------- | -------- | --------------------------------------------------------------------------------- | +| {"--dir"} | | TUI を開始する作業ディレクトリ | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | `-s` | 続行するセッション ID | +| {"--fork"} | | 続行時にセッションをフォーク(`--continue` または `--session` と使用) | +| {"--password"} | `-p` | Basic 認証パスワード(デフォルトは `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic 認証ユーザー名(デフォルトは `OPENCODE_SERVER_USERNAME` または `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### フラグ -| フラグ | 説明 | -| --------- | --------------------------------------------------- | -| `--event` | エージェントを実行するための GitHub モック イベント | -| `--token` | GitHub 個人アクセストークン | +| フラグ | 説明 | +| ------------------------------------- | --------------------------------------------------- | +| {"--event"} | エージェントを実行するための GitHub モック イベント | +| {"--token"} | GitHub 個人アクセストークン | --- @@ -296,10 +300,10 @@ opencode models anthropic #### フラグ -| フラグ | 説明 | -| ----------- | --------------------------------------------------------------- | -| `--refresh` | models.dev からモデルキャッシュを更新します。 | -| `--verbose` | より詳細なモデル出力を使用します (コストなどのメタデータを含む) | +| フラグ | 説明 | +| --------------------------------------- | --------------------------------------------------------------- | +| {"--refresh"} | models.dev からモデルキャッシュを更新します。 | +| {"--verbose"} | より詳細なモデル出力を使用します (コストなどのメタデータを含む) | `--refresh` フラグを使用して、キャッシュされたモデルリストを更新します。これは、新しいモデルがプロバイダーに追加され、それを OpenCode で確認したい場合に便利です。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### フラグ -| フラグ | ショート | 説明 | -| ------------ | -------- | ----------------------------------------------------------------------------------------- | -| `--command` | | 実行するコマンド。引数には message を使用します。 | -| `--continue` | `-c` | 最後のセッションを続行 | -| `--session` | `-s` | 続行するセッション ID | -| `--fork` | | 続行時にセッションをフォーク (`--continue` または `--session` と併用) | -| `--share` | | セッションを共有する | -| `--model` | `-m` | プロバイダー/モデルの形式で使用するモデル | -| `--agent` | | 使用するエージェント | -| `--file` | `-f` | メッセージに添付するファイル | -| `--format` | | 形式: デフォルト (フォーマット済み) または json (生の JSON イベント) | -| `--title` | | セッションのタイトル (値が指定されていない場合は、切り詰められたプロンプトが使用されます) | -| `--attach` | | 実行中の opencode サーバー (http://localhost:4096 など) に接続します。 | -| `--port` | | ローカルサーバーのポート (デフォルトはランダムポート) | +| フラグ | ショート | 説明 | +| ---------------------------------------- | -------- | ----------------------------------------------------------------------------------------- | +| {"--command"} | | 実行するコマンド。引数には message を使用します。 | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | `-s` | 続行するセッション ID | +| {"--fork"} | | 続行時にセッションをフォーク (`--continue` または `--session` と併用) | +| {"--share"} | | セッションを共有する | +| {"--model"} | `-m` | プロバイダー/モデルの形式で使用するモデル | +| {"--agent"} | | 使用するエージェント | +| {"--file"} | `-f` | メッセージに添付するファイル | +| {"--format"} | | 形式: デフォルト (フォーマット済み) または json (生の JSON イベント) | +| {"--title"} | | セッションのタイトル (値が指定されていない場合は、切り詰められたプロンプトが使用されます) | +| {"--attach"} | | 実行中の opencode サーバー (http://localhost:4096 など) に接続します。 | +| {"--password"} | `-p` | Basic 認証パスワード(デフォルトは `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic 認証ユーザー名(デフォルトは `OPENCODE_SERVER_USERNAME` または `opencode`) | +| {"--dir"} | | 実行ディレクトリ、またはアタッチ時のリモートサーバー上のパス | +| {"--variant"} | | モデルバリアント(プロバイダー固有の推論レベル) | +| {"--thinking"} | | 思考ブロックを表示 | +| {"--port"} | | ローカルサーバーのポート (デフォルトはランダムポート) | --- @@ -364,12 +373,12 @@ opencode serve #### フラグ -| フラグ | 説明 | -| ------------ | --------------------------------------- | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | -| `--mdns` | mDNS 検出を有効にする | -| `--cors` | CORS を許可する追加のブラウザーオリジン | +| フラグ | 説明 | +| ---------------------------------------- | --------------------------------------- | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | +| {"--mdns"} | mDNS 検出を有効にする | +| {"--cors"} | CORS を許可する追加のブラウザーオリジン | --- @@ -393,10 +402,10 @@ opencode session list ##### フラグ -| フラグ | ショート | 説明 | -| ------------- | -------- | ---------------------------------------- | -| `--max-count` | `-n` | 最新のセッションを N 個に制限 | -| `--format` | | 出力形式: テーブルまたは json (テーブル) | +| フラグ | ショート | 説明 | +| ----------------------------------------- | -------- | ---------------------------------------- | +| {"--max-count"} | `-n` | 最新のセッションを N 個に制限 | +| {"--format"} | | 出力形式: テーブルまたは json (テーブル) | --- @@ -410,12 +419,12 @@ opencode stats #### フラグ -| フラグ | 説明 | -| ----------- | ------------------------------------------------------------------------------------------ | -| `--days` | 過去 N 日間の統計を表示 (全期間) | -| `--tools` | 表示するツールの数 (すべて) | -| `--models` | モデルの使用状況の内訳を表示 (デフォルトでは非表示)。上位 N 件を表示するには数値を渡します | -| `--project` | プロジェクトでフィルタリング (全プロジェクト、空文字列: 現在のプロジェクト) | +| フラグ | 説明 | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| {"--days"} | 過去 N 日間の統計を表示 (全期間) | +| {"--tools"} | 表示するツールの数 (すべて) | +| {"--models"} | モデルの使用状況の内訳を表示 (デフォルトでは非表示)。上位 N 件を表示するには数値を渡します | +| {"--project"} | プロジェクトでフィルタリング (全プロジェクト、空文字列: 現在のプロジェクト) | --- @@ -460,12 +469,12 @@ opencode web #### フラグ -| フラグ | 説明 | -| ------------ | --------------------------------------- | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | -| `--mdns` | mDNS 検出を有効にする | -| `--cors` | CORS を許可する追加のブラウザーオリジン | +| フラグ | 説明 | +| ---------------------------------------- | --------------------------------------- | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | +| {"--mdns"} | mDNS 検出を有効にする | +| {"--cors"} | CORS を許可する追加のブラウザーオリジン | --- @@ -481,11 +490,11 @@ opencode acp #### フラグ -| フラグ | 説明 | -| ------------ | -------------------- | -| `--cwd` | 作業ディレクトリ | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | +| フラグ | 説明 | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | 作業ディレクトリ | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | --- @@ -499,12 +508,12 @@ opencode uninstall #### フラグ -| フラグ | ショート | 説明 | -| --------------- | -------- | -------------------------------------------- | -| `--keep-config` | `-c` | 構成ファイルを保持する | -| `--keep-data` | `-d` | セッションデータとスナップショットを保持する | -| `--dry-run` | | 削除せずに削除される内容を表示する | -| `--force` | `-f` | 確認プロンプトをスキップする | +| フラグ | ショート | 説明 | +| ------------------------------------------- | -------- | -------------------------------------------- | +| {"--keep-config"} | `-c` | 構成ファイルを保持する | +| {"--keep-data"} | `-d` | セッションデータとスナップショットを保持する | +| {"--dry-run"} | | 削除せずに削除される内容を表示する | +| {"--force"} | `-f` | 確認プロンプトをスキップする | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### フラグ -| フラグ | ショート | 説明 | -| ---------- | -------- | ------------------------------------------------------ | -| `--method` | `-m` | 使用されたインストール方法。curl, npm, pnpm, bun, brew | +| フラグ | ショート | 説明 | +| -------------------------------------- | -------- | ------------------------------------------------------ | +| {"--method"} | `-m` | 使用されたインストール方法。curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI は次のグローバルフラグを受け取ります。 -| フラグ | ショート | 説明 | -| -------------- | -------- | ------------------------------------- | -| `--help` | `-h` | ヘルプを表示 | -| `--version` | `-v` | バージョン番号を出力 | -| `--print-logs` | | ログを標準エラー出力に出力 | -| `--log-level` | | ログレベル (DEBUG、INFO、WARN、ERROR) | +| フラグ | ショート | 説明 | +| ------------------------------------------ | -------- | ------------------------------------- | +| {"--help"} | `-h` | ヘルプを表示 | +| {"--version"} | `-v` | バージョン番号を出力 | +| {"--print-logs"} | | ログを標準エラー出力に出力 | +| {"--log-level"} | | ログレベル (DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/ja/modes.mdx b/packages/web/src/content/docs/ja/modes.mdx deleted file mode 100644 index 8ebe7f5e68..0000000000 --- a/packages/web/src/content/docs/ja/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: モード -description: さまざまなユースケースに応じたさまざまなモード。 ---- - -:::caution -モードは、OpenCode 設定の `agent` オプションを通じて設定されるようになりました。 -`mode` オプションは非推奨になりました。 [詳細はこちら](/docs/agents)。 -::: -モードを使用すると、さまざまなユースケースに合わせて動作、ツール、プロンプトをカスタマイズできます。 - -**Build** と **Plan** という 2 つの組み込みモードが付属しています。カスタマイズできます -これらを使用するか、OpenCode 設定を通じて独自の設定を行います。 - -セッション中にモードを切り替えることも、設定ファイルでモードを構成することもできます。 - ---- - -## 組み込み - -OpenCode には 2 つの組み込みモードが付属しています。 - ---- - -### Build - -Build は、すべてのツールが有効になっている **デフォルト** モードです。これは、ファイル操作やシステムコマンドへのフルアクセスが必要な開発作業の標準モードです。 - ---- - -### Plan - -計画と分析のために設計された制限付きモード。Plan モードでは、次のツールはデフォルトで無効になっています。 - -- `write` - 新しいファイルを作成できません -- `edit` - 計画自体の詳細を示す `.opencode/plans/*.md` にあるファイルを除き、既存のファイルを変更できません -- `patch` - パッチを適用できません -- `bash` - シェルコマンドを実行できません - -このモードは、コードベースに実際の変更を加えずに、AI にコードを分析させたり、変更を提案したり、計画を作成させたい場合に便利です。 - ---- - -## 切り替え - -セッション中に _Tab_ キーを使用してモードを切り替えることができます。または、設定された `switch_mode` キーバインド。 - -参照: [コードのフォーマット設定については、Formatters](/docs/formatters)。 - ---- - -## 設定 - -組み込みモードをカスタマイズしたり、構成を通じて独自のモードを作成したりできます。モードは次の 2 つの方法で設定できます。 - -### JSON 設定 - -`opencode.json` 設定ファイルでモードを構成します。 - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 設定 - -Markdown ファイルを使用してモードを定義することもできます。それらを次の場所に置きます。 - -- グローバル: `~/.config/opencode/modes/` -- プロジェクト: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown ファイル名はモード名になります (例: `review.md` は `review` モードを作成します)。 - -これらの設定オプションを詳しく見てみましょう。 - ---- - -### モデル - -`model` 設定を使用して、このモードのデフォルトモデルをオーバーライドします。さまざまなタスクに最適化されたさまざまなモデルを使用する場合に役立ちます。たとえば、計画にはより高速なモデルを、実装にはより有能なモデルを使用します。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 温度 - -`temperature` 設定を使用して、AI の応答のランダム性と創造性を制御します。値が低いほど、応答はより集中的かつ決定的になりますが、値が高いほど、創造性と変動性が高まります。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -通常、温度値の範囲は 0.0 ~ 1.0 です。 - -- **0.0-0.2**: 非常に焦点が絞られた決定的な応答。コード分析と計画に最適です。 -- **0.3-0.5**: 創造性を備えたバランスの取れた応答。一般的な開発タスクに適しています。 -- **0.6-1.0**: より創造的で多様な応答。ブレーンストーミングや探索に役立ちます。 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -温度が指定されていない場合、OpenCode はモデル固有のデフォルトを使用します (通常、ほとんどのモデルでは 0、Qwen モデルでは 0.55)。 - ---- - -### プロンプト - -`prompt` 設定を使用して、このモードのカスタムシステムプロンプトファイルを指定します。プロンプトファイルには、モードの目的に固有の指示が含まれている必要があります。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -このパスは、設定ファイルが配置されている場所に対する相対パスです。したがって、これはグローバルな OpenCode 設定とプロジェクト固有の設定の両方で機能します。 - ---- - -### ツール - -`tools` 設定を使用して、このモードでどのツールを使用できるかを制御します。特定のツールを `true` または `false` に設定することで、有効または無効にすることができます。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -ツールが指定されていない場合は、すべてのツールがデフォルトで有効になります。 - ---- - -#### 利用可能なツール - -ここでは、モード設定を通じて制御できるすべてのツールを示します。 - -| ツール | 説明 | -| ----------- | -------------------------- | -| `bash` | シェルコマンドを実行する | -| `edit` | 既存のファイルを変更する | -| `write` | 新しいファイルを作成する | -| `read` | ファイルの内容を読み取る | -| `grep` | ファイルの内容を検索 | -| `glob` | パターンでファイルを検索 | -| `patch` | ファイルにパッチを適用する | -| `todowrite` | ToDo リストを管理する | -| `webfetch` | Web コンテンツを取得する | - ---- - -## カスタムモード - -構成に追加することで、独自のカスタムモードを作成できます。両方のアプローチを使用した例を次に示します。 - -### JSON 設定の使用 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Markdown ファイルの使用 - -モードファイルをプロジェクト固有モードの場合は `.opencode/modes/` に、グローバルモードの場合は `~/.config/opencode/modes/` に作成します。 - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### ユースケース - -さまざまなモードの一般的な使用例をいくつか示します。 - -- **ビルドモード**: すべてのツールを有効にした完全な開発作業 -- **計画モード**: 変更を加えずに分析および計画を立てる -- **レビューモード**: 読み取り専用アクセスとドキュメントツールによるコードレビュー -- **デバッグモード**: bash および読み取りツールを有効にして調査に重点を置きます -- **ドキュメントモード**: ファイル操作を使用してドキュメントを作成しますが、システムコマンドは使用しません - -また、さまざまなユースケースにさまざまなモデルが適していることがわかるかもしれません。 diff --git a/packages/web/src/content/docs/ja/providers.mdx b/packages/web/src/content/docs/ja/providers.mdx index 8017d0882e..c969c6d4a0 100644 --- a/packages/web/src/content/docs/ja/providers.mdx +++ b/packages/web/src/content/docs/ja/providers.mdx @@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。 --- -### Firmware +### FrogBot -1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 +1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 2. `/connect` コマンドを実行し、**ファームウェア**を検索します。 diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 5488aaf81c..86970638c7 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -23,7 +23,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_list": "l", "session_timeline": "g", "session_fork": "none", - "session_rename": "none", + "session_rename": "ctrl+r", "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", @@ -105,6 +105,13 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. } ``` +:::note +On Windows, the defaults for `input_undo` and `terminal_suspend` are different: + +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). +- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. + ::: + --- ## Leader key diff --git a/packages/web/src/content/docs/ko/cli.mdx b/packages/web/src/content/docs/ko/cli.mdx index b0ce10567e..7b829c7f37 100644 --- a/packages/web/src/content/docs/ko/cli.mdx +++ b/packages/web/src/content/docs/ko/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 플래그 -| 플래그 | 축약 | 설명 | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--continue` | `-c` | 마지막 세션 이어서 실행 | -| `--session` | `-s` | 이어서 실행할 세션 ID | -| `--fork` | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | -| `--prompt` | | 사용할 프롬프트 | -| `--model` | `-m` | 사용할 모델 (`provider/model` 형식) | -| `--agent` | | 사용할 에이전트 | -| `--port` | | 수신 포트 | -| `--hostname` | | 수신 호스트명 | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------- | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | +| {"--prompt"} | | 사용할 프롬프트 | +| {"--model"} | `-m` | 사용할 모델 (`provider/model` 형식) | +| {"--agent"} | | 사용할 에이전트 | +| {"--port"} | | 수신 포트 | +| {"--hostname"} | | 수신 호스트명 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 플래그 -| 플래그 | 축약 | 설명 | -| ----------- | ---- | -------------------------- | -| `--dir` | | TUI를 시작할 작업 디렉터리 | -| `--session` | `-s` | 이어서 실행할 세션 ID | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------- | +| {"--dir"} | | TUI를 시작할 작업 디렉터리 | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 이어서 실행할 때 세션 포크 (`--continue` 또는 `--session`과 함께 사용) | +| {"--password"} | `-p` | 기본 인증 비밀번호 (기본값: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 기본 인증 사용자 이름 (기본값: `OPENCODE_SERVER_USERNAME` 또는 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 플래그 -| 플래그 | 설명 | -| --------- | ------------------------- | -| `--event` | 실행할 GitHub 모의 이벤트 | -| `--token` | GitHub 개인 액세스 토큰 | +| 플래그 | 설명 | +| ------------------------------------- | ------------------------- | +| {"--event"} | 실행할 GitHub 모의 이벤트 | +| {"--token"} | GitHub 개인 액세스 토큰 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 플래그 -| 플래그 | 설명 | -| ----------- | ------------------------------------------------- | -| `--refresh` | models.dev에서 모델 캐시 새로고침 | -| `--verbose` | 더 자세한 모델 출력 사용(비용 등 메타데이터 포함) | +| 플래그 | 설명 | +| --------------------------------------- | ------------------------------------------------- | +| {"--refresh"} | models.dev에서 모델 캐시 새로고침 | +| {"--verbose"} | 더 자세한 모델 출력 사용(비용 등 메타데이터 포함) | `--refresh` 플래그를 사용하면 캐시된 모델 목록을 갱신할 수 있습니다. provider에 새 모델이 추가된 뒤 OpenCode에서 바로 확인하고 싶을 때 유용합니다. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 플래그 -| 플래그 | 축약 | 설명 | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--command` | | 실행할 명령(인수는 message로 전달) | -| `--continue` | `-c` | 마지막 세션 이어서 실행 | -| `--session` | `-s` | 이어서 실행할 세션 ID | -| `--fork` | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | -| `--share` | | 세션 공유 | -| `--model` | `-m` | 사용할 모델 (`provider/model` 형식) | -| `--agent` | | 사용할 에이전트 | -| `--file` | `-f` | 메시지에 첨부할 파일 | -| `--format` | | 출력 형식: default(포맷됨) 또는 json(원시 JSON 이벤트) | -| `--title` | | 세션 제목(값이 없으면 프롬프트를 잘라 자동 생성) | -| `--attach` | | 실행 중인 opencode 서버에 연결(예: http://localhost:4096) | -| `--port` | | 로컬 서버 포트(기본값: 랜덤 포트) | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------- | +| {"--command"} | | 실행할 명령(인수는 message로 전달) | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | +| {"--share"} | | 세션 공유 | +| {"--model"} | `-m` | 사용할 모델 (`provider/model` 형식) | +| {"--agent"} | | 사용할 에이전트 | +| {"--file"} | `-f` | 메시지에 첨부할 파일 | +| {"--format"} | | 출력 형식: default(포맷됨) 또는 json(원시 JSON 이벤트) | +| {"--title"} | | 세션 제목(값이 없으면 프롬프트를 잘라 자동 생성) | +| {"--attach"} | | 실행 중인 opencode 서버에 연결(예: http://localhost:4096) | +| {"--password"} | `-p` | 기본 인증 비밀번호 (기본값: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 기본 인증 사용자 이름 (기본값: `OPENCODE_SERVER_USERNAME` 또는 `opencode`) | +| {"--dir"} | | 실행할 디렉터리, 또는 연결 시 원격 서버 경로 | +| {"--variant"} | | 모델 변형 (제공자별 추론 수준) | +| {"--thinking"} | | 사고 블록 표시 | +| {"--port"} | | 로컬 서버 포트(기본값: 랜덤 포트) | --- @@ -364,12 +373,12 @@ opencode serve #### 플래그 -| 플래그 | 설명 | -| ------------ | --------------------------------- | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | -| `--mdns` | mDNS 검색 활성화 | -| `--cors` | 허용할 추가 브라우저 origin(CORS) | +| 플래그 | 설명 | +| ---------------------------------------- | --------------------------------- | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | +| {"--mdns"} | mDNS 검색 활성화 | +| {"--cors"} | 허용할 추가 브라우저 origin(CORS) | --- @@ -393,10 +402,10 @@ opencode session list ##### 플래그 -| 플래그 | 축약 | 설명 | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | 최근 N개 세션만 표시 | -| `--format` | | 출력 형식: table 또는 json(기본 table) | +| 플래그 | 축약 | 설명 | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | 최근 N개 세션만 표시 | +| {"--format"} | | 출력 형식: table 또는 json(기본 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 플래그 -| 플래그 | 설명 | -| ----------- | ------------------------------------------------------------ | -| `--days` | 최근 N일 통계 표시(기본값: 전체 기간) | -| `--tools` | 표시할 도구 개수(기본값: 전체) | -| `--models` | 모델 사용량 상세 표시(기본 숨김). 숫자를 주면 상위 N개 표시 | -| `--project` | 프로젝트 필터(기본: 전체 프로젝트, 빈 문자열: 현재 프로젝트) | +| 플래그 | 설명 | +| --------------------------------------- | ------------------------------------------------------------ | +| {"--days"} | 최근 N일 통계 표시(기본값: 전체 기간) | +| {"--tools"} | 표시할 도구 개수(기본값: 전체) | +| {"--models"} | 모델 사용량 상세 표시(기본 숨김). 숫자를 주면 상위 N개 표시 | +| {"--project"} | 프로젝트 필터(기본: 전체 프로젝트, 빈 문자열: 현재 프로젝트) | --- @@ -460,12 +469,12 @@ opencode web #### 플래그 -| 플래그 | 설명 | -| ------------ | --------------------------------- | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | -| `--mdns` | mDNS 검색 활성화 | -| `--cors` | 허용할 추가 브라우저 origin(CORS) | +| 플래그 | 설명 | +| ---------------------------------------- | --------------------------------- | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | +| {"--mdns"} | mDNS 검색 활성화 | +| {"--cors"} | 허용할 추가 브라우저 origin(CORS) | --- @@ -481,11 +490,11 @@ opencode acp #### 플래그 -| 플래그 | 설명 | -| ------------ | ------------- | -| `--cwd` | 작업 디렉터리 | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | +| 플래그 | 설명 | +| ---------------------------------------- | ------------- | +| {"--cwd"} | 작업 디렉터리 | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 플래그 -| 플래그 | 축약 | 설명 | -| --------------- | ---- | ------------------------------- | -| `--keep-config` | `-c` | 설정 파일 유지 | -| `--keep-data` | `-d` | 세션 데이터와 스냅샷 유지 | -| `--dry-run` | | 실제 삭제 없이 삭제 대상만 표시 | -| `--force` | `-f` | 확인 프롬프트 건너뛰기 | +| 플래그 | 축약 | 설명 | +| ------------------------------------------- | ---- | ------------------------------- | +| {"--keep-config"} | `-c` | 설정 파일 유지 | +| {"--keep-data"} | `-d` | 세션 데이터와 스냅샷 유지 | +| {"--dry-run"} | | 실제 삭제 없이 삭제 대상만 표시 | +| {"--force"} | `-f` | 확인 프롬프트 건너뛰기 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 플래그 -| 플래그 | 축약 | 설명 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 설치 방식 지정: curl, npm, pnpm, bun, brew | +| 플래그 | 축약 | 설명 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 설치 방식 지정: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI는 아래 전역 플래그를 지원합니다. -| 플래그 | 축약 | 설명 | -| -------------- | ---- | ----------------------------------- | -| `--help` | `-h` | 도움말 표시 | -| `--version` | `-v` | 버전 출력 | -| `--print-logs` | | 로그를 stderr로 출력 | -| `--log-level` | | 로그 레벨(DEBUG, INFO, WARN, ERROR) | +| 플래그 | 축약 | 설명 | +| ------------------------------------------ | ---- | ----------------------------------- | +| {"--help"} | `-h` | 도움말 표시 | +| {"--version"} | `-v` | 버전 출력 | +| {"--print-logs"} | | 로그를 stderr로 출력 | +| {"--log-level"} | | 로그 레벨(DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ko/modes.mdx b/packages/web/src/content/docs/ko/modes.mdx deleted file mode 100644 index 32746d1d05..0000000000 --- a/packages/web/src/content/docs/ko/modes.mdx +++ /dev/null @@ -1,327 +0,0 @@ ---- -title: 모드 -description: 다양한 사용 사례를 위한 다양한 모드. ---- - -:::caution -모드는 opencode 설정에서 `agent` 옵션을 통해 구성되어 있습니다. 더 보기 -`mode` 옵션이 이제 비활성화되었습니다. [더 알아보기](/docs/agents). -::: - -opencode의 모드는 다른 사용 사례에 대한 행동, 도구 및 프롬프트를 사용자 정의 할 수 있습니다. - -그것은 두 개의 내장 모드와 함께 제공됩니다 : ** 빌드 ** 및 ** 계획 **. 사용자 정의 할 수 있습니다. -opencode config를 통해 자체를 구성합니다. - -세션 중에 모드를 전환하거나 구성 파일에서 구성할 수 있습니다. - ---- - -## 내장 - -opencode는 2개의 붙박이 형태로 옵니다. - ---- - -### 빌드 - -빌드는 **default** 모드로 모든 도구가 활성화됩니다. 이것은 파일 운영 및 시스템 명령에 대한 전체 액세스가 필요한 개발 작업을위한 표준 모드입니다. - ---- - -## 계획 - -계획 및 분석을 위해 설계된 제한 모드. 계획 모드에서 다음 도구는 기본적으로 비활성화됩니다: - -- `write` - 새로운 파일을 만들 수 없습니다 -- `edit` - `.opencode/plans/*.md`에 위치한 파일을 제외하고 기존 파일을 수정할 수 없습니다. -- `patch` - 패치 적용 -- `bash` - shell 명령을 실행할 수 없습니다 - -이 모드는 코드를 분석하기 위해 AI를 원할 때 유용합니다. 변경 사항을 제안하거나 코드베이스에 실제 수정없이 계획을 만들 수 있습니다. - ---- - -## 전환 - -Tab 키를 사용하여 세션 중에 모드를 전환할 수 있습니다. 또는 당신의 형성된 `switch_mode` keybind. - -참조 : [Formatters](/docs/formatters) 코드 형식 설정에 대한 정보. - ---- - -## 구성 - -내장 모드를 사용자 정의하거나 구성을 통해 자신의 만들 수 있습니다. 형태는 2가지 방법으로 형성될 수 있습니다: - -### JSON 구성 - -`opencode.json` 설정 파일에서 모드 구성: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 구성 - -Markdown 파일을 사용하여 모드를 정의할 수 있습니다. 그들에 게: - -- 글로벌: `~/.config/opencode/modes/` -- 프로젝트: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 파일 이름은 모드 이름 (예 : `review.md`는 `review` 모드를 만듭니다)이됩니다. - -이 구성 옵션을 자세히 살펴봅시다. - ---- - -### 모델 - -`model` config를 사용하여 이 모드의 기본 모델을 무시합니다. 다른 작업에 최적화 된 다른 모델을 사용하는 데 유용합니다. 예를 들어, 계획을위한 빠른 모델, 구현을위한 더 많은 모델. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 온도 - -`temperature` config와 AI의 응답의 임의성과 창의성을 제어합니다. 더 낮은 값은 더 집중하고 세심한 응답을 만듭니다. 더 높은 값은 창의력과 가변성을 증가하면서. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -온도 값은 일반적으로 0.0에서 1.0에 배열합니다: - -- **0.0-0.2**: 매우 집중하고 신중한 응답, 코드 분석 및 계획에 이상 -**0.3-0.5**: 일부 창의력과 균형 잡힌 응답, 일반 개발 작업에 좋은 -- **0.6-1.0**: 더 창조적이고 다양한 응답, 브레인스토밍 및 탐험에 유용한 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -온도가 지정되지 않은 경우, opencode는 모델별 기본 (일반적으로 0 대부분의 모델에 대한, 0.55 Qwen 모델)을 사용합니다. - ---- - -#### 프롬프트 - -`prompt` config를 가진 이 형태를 위한 주문 체계 prompt 파일을 지정하십시오. prompt 파일은 모드의 목적에 특정한 지시를 포함해야 합니다. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -이 경로는 config 파일이 있는 곳에 관계됩니다. 그래서이 작품 -글로벌 opencode config 및 프로젝트 특정 구성 모두. - ---- - -## 도구 - -이 모드에서는 `tools` config를 사용할 수 있습니다. `true` 또는 `false`로 설정하여 특정 도구를 활성화하거나 비활성화 할 수 있습니다. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -도구가 지정되지 않은 경우, 모든 도구는 기본적으로 활성화됩니다. - ---- - -### 유효한 도구 - -여기에 모든 도구는 모드 구성을 통해 제어 할 수 있습니다. - -| 도구 | 설명 | -| ----------- | --------------------- | -| `bash` | shell 명령 실행 | -| `edit` | 기존 파일 수정 | -| `write` | 새 파일 만들기 | -| `read` | 읽는 파일 내용 | -| `grep` | 파일 검색 | -| `glob` | 패턴으로 찾기 | -| `patch` | 파일에 패치 적용 | -| `todowrite` | 할 일(Todo) 목록 관리 | -| `webfetch` | 웹사이트 가져오기 | - ---- - -## 사용자 정의 모드 - -구성에 추가하여 사용자 정의 모드를 만들 수 있습니다. 여기에는 두 가지 접근법이 있습니다. - -### JSON 구성 사용 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### markdown 파일 사용 - -프로젝트 별 모드 또는 `~/.config/opencode/modes/`의 모드 파일을 만들 수 있습니다. - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 사용 사례 - -다음은 다른 모드에 대한 일반적인 사용 사례입니다. - -- **Build 모드**: 모든 도구와 함께 전체 개발 작업 -- **Plan 모드**: 변화없이 분석 및 계획 -**Review 모드**: Code review with read-only access plus 문서 도구 -- **Debug 모드**: bash 및 읽기 도구와 함께 조사에 집중 -- **Docs 모드**: 파일 작업과 문서 작성하지만 시스템 명령 없음 - -다른 모델을 찾을 수 있습니다 다른 사용 케이스에 대 한 좋은. diff --git a/packages/web/src/content/docs/ko/providers.mdx b/packages/web/src/content/docs/ko/providers.mdx index 6ca3afccc3..87278bef23 100644 --- a/packages/web/src/content/docs/ko/providers.mdx +++ b/packages/web/src/content/docs/ko/providers.mdx @@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세 --- -### Firmware +### FrogBot -1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. +1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. -2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오. +2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오. ```txt /connect ``` -3. Firmware API 키를 입력하십시오. +3. FrogBot API 키를 입력하십시오. ```txt ┌ API key diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index ad6a4644df..5854fe1f1a 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -3,7 +3,7 @@ title: LSP Servers description: OpenCode integrates with your LSP servers. --- -OpenCode integrates with your Language Server Protocol (LSP) to help the LLM interact with your codebase. It uses diagnostics to provide feedback to the LLM. +OpenCode can integrate with your Language Server Protocol (LSP) to help the LLM interact with your codebase. It uses diagnostics to provide feedback to the LLM. --- @@ -48,7 +48,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | yaml-ls | .yaml, .yml | Auto-installs Red Hat yaml-language-server | | zls | .zig, .zon | `zig` command available | -LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met. +When LSP is enabled, servers start when one of the above file extensions is detected and the requirements are met. :::note You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_LSP_DOWNLOAD` environment variable to `true`. @@ -58,7 +58,7 @@ You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_ ## How It Works -When opencode opens a file, it: +When LSP is enabled and opencode opens a file, it: 1. Checks the file extension against all enabled LSP servers. 2. Starts the appropriate LSP server if not already running. @@ -67,7 +67,18 @@ When opencode opens a file, it: ## Configure -You can customize LSP servers through the `lsp` section in your opencode config. +You can enable and customize LSP servers through the `lsp` section in your opencode config. + +To enable all built-in LSP servers, set `lsp` to `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom servers. ```json title="opencode.json" { @@ -76,7 +87,9 @@ You can customize LSP servers through the `lsp` section in your opencode config. } ``` -Each LSP server supports the following: +Each configured LSP server entry supports the following: + +Server entries need `command` unless they only disable a server. | Property | Type | Description | | ---------------- | -------- | ------------------------------------------------- | @@ -94,11 +107,12 @@ Let's look at some examples. Use the `env` property to set environment variables when starting the LSP server: -```json title="opencode.json" {5-7} +```json title="opencode.json" {5-8} { "$schema": "https://opencode.ai/config.json", "lsp": { "rust": { + "command": ["rust-analyzer"], "env": { "RUST_LOG": "debug" } @@ -113,11 +127,13 @@ Use the `env` property to set environment variables when starting the LSP server Use the `initialization` property to pass initialization options to the LSP server. These are server-specific settings sent during the LSP `initialize` request: -```json title="opencode.json" {5-9} +```json title="opencode.json" {5-13} { "$schema": "https://opencode.ai/config.json", "lsp": { - "typescript": { + "custom-lsp": { + "command": ["custom-lsp-server", "--stdio"], + "extensions": [".custom"], "initialization": { "preferences": { "importModuleSpecifierPreference": "relative" @@ -136,7 +152,7 @@ Initialization options vary by LSP server. Check your LSP server's documentation ### Disabling LSP servers -To disable **all** LSP servers globally, set `lsp` to `false`: +If `lsp` is omitted, all LSP servers are disabled. To disable all LSP servers after another config enabled them, set `lsp` to `false`: ```json title="opencode.json" {3} { diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx deleted file mode 100644 index b8ea697399..0000000000 --- a/packages/web/src/content/docs/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modes -description: Different modes for different use cases. ---- - -:::caution -Modes are now configured through the `agent` option in the opencode config. The -`mode` option is now deprecated. [Learn more](/docs/agents). -::: - -Modes in opencode allow you to customize the behavior, tools, and prompts for different use cases. - -It comes with two built-in modes: **build** and **plan**. You can customize -these or configure your own through the opencode config. - -You can switch between modes during a session or configure them in your config file. - ---- - -## Built-in - -opencode comes with two built-in modes. - ---- - -### Build - -Build is the **default** mode with all tools enabled. This is the standard mode for development work where you need full access to file operations and system commands. - ---- - -### Plan - -A restricted mode designed for planning and analysis. In plan mode, the following tools are disabled by default: - -- `write` - Cannot create new files -- `edit` - Cannot modify existing files, except for files located at `.opencode/plans/*.md` to detail the plan itself -- `patch` - Cannot apply patches -- `bash` - Cannot execute shell commands - -This mode is useful when you want the AI to analyze code, suggest changes, or create plans without making any actual modifications to your codebase. - ---- - -## Switching - -You can switch between modes during a session using the _Tab_ key. Or your configured `switch_mode` keybind. - -See also: [Formatters](/docs/formatters) for information about code formatting configuration. - ---- - -## Configure - -You can customize the built-in modes or create your own through configuration. Modes can be configured in two ways: - -### JSON Configuration - -Configure modes in your `opencode.json` config file: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown Configuration - -You can also define modes using markdown files. Place them in: - -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -The markdown file name becomes the mode name (e.g., `review.md` creates a `review` mode). - -Let's look at these configuration options in detail. - ---- - -### Model - -Use the `model` config to override the default model for this mode. Useful for using different models optimized for different tasks. For example, a faster model for planning, a more capable model for implementation. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperature - -Control the randomness and creativity of the AI's responses with the `temperature` config. Lower values make responses more focused and deterministic, while higher values increase creativity and variability. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperature values typically range from 0.0 to 1.0: - -- **0.0-0.2**: Very focused and deterministic responses, ideal for code analysis and planning -- **0.3-0.5**: Balanced responses with some creativity, good for general development tasks -- **0.6-1.0**: More creative and varied responses, useful for brainstorming and exploration - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -If no temperature is specified, opencode uses model-specific defaults (typically 0 for most models, 0.55 for Qwen models). - ---- - -### Prompt - -Specify a custom system prompt file for this mode with the `prompt` config. The prompt file should contain instructions specific to the mode's purpose. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -This path is relative to where the config file is located. So this works for -both the global opencode config and the project specific config. - ---- - -### Tools - -Control which tools are available in this mode with the `tools` config. You can enable or disable specific tools by setting them to `true` or `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -If no tools are specified, all tools are enabled by default. - ---- - -#### Available tools - -Here are all the tools can be controlled through the mode config. - -| Tool | Description | -| ----------- | ---------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | - ---- - -## Custom modes - -You can create your own custom modes by adding them to the configuration. Here are examples using both approaches: - -### Using JSON configuration - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Using markdown files - -Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Use cases - -Here are some common use cases for different modes. - -- **Build mode**: Full development work with all tools enabled -- **Plan mode**: Analysis and planning without making changes -- **Review mode**: Code review with read-only access plus documentation tools -- **Debug mode**: Focused on investigation with bash and read tools enabled -- **Docs mode**: Documentation writing with file operations but no system commands - -You might also find different models are good for different use cases. diff --git a/packages/web/src/content/docs/nb/cli.mdx b/packages/web/src/content/docs/nb/cli.mdx index 8312a1a7c5..824a3dcadd 100644 --- a/packages/web/src/content/docs/nb/cli.mdx +++ b/packages/web/src/content/docs/nb/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flagg -| Flagg | Kort | Beskrivelse | -| ------------ | ---- | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Fortsett siste økt | -| `--session` | `-s` | Økt ID for å fortsette | -| `--fork` | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | -| `--prompt` | | Ledetekst som skal brukes | -| `--model` | `-m` | Modell å bruke i form av leverandør/modell | -| `--agent` | | Agent som skal brukes | -| `--port` | | Port å lytte på | -| `--hostname` | | Vertsnavn å lytte på | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--prompt"} | | Ledetekst som skal brukes | +| {"--model"} | `-m` | Modell å bruke i form av leverandør/modell | +| {"--agent"} | | Agent som skal brukes | +| {"--port"} | | Port å lytte på | +| {"--hostname"} | | Vertsnavn å lytte på | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flagg -| Flagg | Kort | Beskrivelse | -| ----------- | ---- | --------------------------------- | -| `--dir` | | Arbeidskatalog for å starte TUI i | -| `--session` | `-s` | Økt ID for å fortsette | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Arbeidskatalog for å starte TUI i | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--password"} | `-p` | Passord for grunnleggende autentisering (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brukernavn for grunnleggende autentisering (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flagg -| Flagg | Beskrivelse | -| --------- | -------------------------------------------- | -| `--event` | GitHub mock-hendelse agenten skal kjøres for | -| `--token` | GitHub personlig tilgangsnøkkel | +| Flagg | Beskrivelse | +| ------------------------------------- | -------------------------------------------- | +| {"--event"} | GitHub mock-hendelse agenten skal kjøres for | +| {"--token"} | GitHub personlig tilgangsnøkkel | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flagg -| Flagg | Beskrivelse | -| ----------- | ------------------------------------------------------------------- | -| `--refresh` | Oppdater modellbufferen fra models.dev | -| `--verbose` | Bruk mer detaljert modellutdata (inkluderer metadata som kostnader) | +| Flagg | Beskrivelse | +| --------------------------------------- | ------------------------------------------------------------------- | +| {"--refresh"} | Oppdater modellbufferen fra models.dev | +| {"--verbose"} | Bruk mer detaljert modellutdata (inkluderer metadata som kostnader) | Bruk `--refresh`-flagget for å oppdatere den bufrede modelllisten. Dette er nyttig når nye modeller er lagt til en leverandør og du vil se dem i OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flagg -| Flagg | Kort | Beskrivelse | -| ------------ | ---- | ------------------------------------------------------------------------ | -| `--command` | | Kommandoen for å kjøre, bruk melding for args | -| `--continue` | `-c` | Fortsett siste økt | -| `--session` | `-s` | Økt ID for å fortsette | -| `--fork` | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | -| `--share` | | Del økten | -| `--model` | `-m` | Modell å bruke i form av leverandør/modell | -| `--agent` | | Agent å bruke | -| `--file` | `-f` | Fil(er) som skal legges ved meldingen | -| `--format` | | Format: standard (formatert) eller json (rå JSON hendelser) | -| `--title` | | Tittel for økten (bruker avkortet ledetekst hvis ingen verdi er oppgitt) | -| `--attach` | | Koble til en kjørende OpenCode-server (f.eks. http://localhost:4096) | -| `--port` | | Port for den lokale serveren (standard til tilfeldig port) | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| {"--command"} | | Kommandoen for å kjøre, bruk melding for args | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--share"} | | Del økten | +| {"--model"} | `-m` | Modell å bruke i form av leverandør/modell | +| {"--agent"} | | Agent å bruke | +| {"--file"} | `-f` | Fil(er) som skal legges ved meldingen | +| {"--format"} | | Format: standard (formatert) eller json (rå JSON hendelser) | +| {"--title"} | | Tittel for økten (bruker avkortet ledetekst hvis ingen verdi er oppgitt) | +| {"--attach"} | | Koble til en kjørende OpenCode-server (f.eks. http://localhost:4096) | +| {"--password"} | `-p` | Passord for grunnleggende autentisering (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brukernavn for grunnleggende autentisering (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | +| {"--dir"} | | Katalog å kjøre i, eller sti på fjernserveren ved tilkobling | +| {"--variant"} | | Modellvariant (leverandørspesifikk resonneringsinnsats) | +| {"--thinking"} | | Vis tenkeblokker | +| {"--port"} | | Port for den lokale serveren (standard til tilfeldig port) | --- @@ -364,12 +373,12 @@ Dette starter en HTTP-server som gir API tilgang til OpenCode-funksjonalitet ute #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------------------------------------- | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | -| `--mdns` | Aktiver mDNS-oppdagelse | -| `--cors` | Ytterligere nettleseropprinnelse som tillater CORS | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------------------------------------- | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | +| {"--mdns"} | Aktiver mDNS-oppdagelse | +| {"--cors"} | Ytterligere nettleseropprinnelse som tillater CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flagg -| Flagg | Kort | Beskrivelse | -| ------------- | ---- | ---------------------------------------- | -| `--max-count` | `-n` | Begrens til N siste økter | -| `--format` | | Utdataformat: tabell eller json (tabell) | +| Flagg | Kort | Beskrivelse | +| ----------------------------------------- | ---- | ---------------------------------------- | +| {"--max-count"} | `-n` | Begrens til N siste økter | +| {"--format"} | | Utdataformat: tabell eller json (tabell) | --- @@ -410,12 +419,12 @@ opencode stats #### Flagg -| Flagg | Beskrivelse | -| ----------- | -------------------------------------------------------------------------------- | -| `--days` | Vis statistikk for de siste N dagene (hele tiden) | -| `--tools` | Antall verktøy som skal vises (alle) | -| `--models` | Vis oversikt over modellbruk (skjult som standard). Gi et tall for å vise topp N | -| `--project` | Filtrer etter prosjekt (alle prosjekter, tom streng: gjeldende prosjekt) | +| Flagg | Beskrivelse | +| --------------------------------------- | -------------------------------------------------------------------------------- | +| {"--days"} | Vis statistikk for de siste N dagene (hele tiden) | +| {"--tools"} | Antall verktøy som skal vises (alle) | +| {"--models"} | Vis oversikt over modellbruk (skjult som standard). Gi et tall for å vise topp N | +| {"--project"} | Filtrer etter prosjekt (alle prosjekter, tom streng: gjeldende prosjekt) | --- @@ -460,12 +469,12 @@ Dette starter en HTTP-server og åpner en nettleser for å få tilgang til OpenC #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------------------------------------- | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | -| `--mdns` | Aktiver mDNS-oppdagelse | -| `--cors` | Ytterligere nettleseropprinnelse som tillater CORS | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------------------------------------- | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | +| {"--mdns"} | Aktiver mDNS-oppdagelse | +| {"--cors"} | Ytterligere nettleseropprinnelse som tillater CORS | --- @@ -481,11 +490,11 @@ Denne kommandoen starter en ACP-server som kommuniserer via stdin/stdout ved å #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------- | -| `--cwd` | Arbeidskatalog | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | Arbeidskatalog | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flagg -| Flagg | Kort | Beskrivelse | -| --------------- | ---- | --------------------------------------------- | -| `--keep-config` | `-c` | Behold konfigurasjonsfiler | -| `--keep-data` | `-d` | Behold øktdata og øyeblikksbilder | -| `--dry-run` | | Vis hva som ville blitt fjernet uten å fjerne | -| `--force` | `-f` | Hopp over bekreftelsesforespørsler | +| Flagg | Kort | Beskrivelse | +| ------------------------------------------- | ---- | --------------------------------------------- | +| {"--keep-config"} | `-c` | Behold konfigurasjonsfiler | +| {"--keep-data"} | `-d` | Behold øktdata og øyeblikksbilder | +| {"--dry-run"} | | Vis hva som ville blitt fjernet uten å fjerne | +| {"--force"} | `-f` | Hopp over bekreftelsesforespørsler | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flagg -| Flagg | Kort | Beskrivelse | -| ---------- | ---- | -------------------------------------------------------------- | -| `--method` | `-m` | Installasjonsmetoden som ble brukt: curl, npm, pnpm, bun, brew | +| Flagg | Kort | Beskrivelse | +| -------------------------------------- | ---- | -------------------------------------------------------------- | +| {"--method"} | `-m` | Installasjonsmetoden som ble brukt: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI bruker følgende globale flagg. -| Flagg | Kort | Beskrivelse | -| -------------- | ---- | ----------------------------------- | -| `--help` | `-h` | Vis hjelp | -| `--version` | `-v` | Skriv ut versjonsnummer | -| `--print-logs` | | Skriv ut logger til stderr | -| `--log-level` | | Loggnivå (DEBUG, INFO, WARN, ERROR) | +| Flagg | Kort | Beskrivelse | +| ------------------------------------------ | ---- | ----------------------------------- | +| {"--help"} | `-h` | Vis hjelp | +| {"--version"} | `-v` | Skriv ut versjonsnummer | +| {"--print-logs"} | | Skriv ut logger til stderr | +| {"--log-level"} | | Loggnivå (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/nb/modes.mdx b/packages/web/src/content/docs/nb/modes.mdx deleted file mode 100644 index e99c511be6..0000000000 --- a/packages/web/src/content/docs/nb/modes.mdx +++ /dev/null @@ -1,328 +0,0 @@ ---- -title: Moduser -description: Ulike moduser for forskjellige brukstilfeller. ---- - -:::caution -Moduser konfigureres nå gjennom alternativet `agent` i OpenCode-konfigurasjonen. -Alternativet `mode` er nå utdatert. [Finn ut mer](/docs/agents). -::: - -Moduser i OpenCode lar deg tilpasse oppførselen, verktøyene og prompter for ulike brukstilfeller. - -Den kommer med to innebygde moduser: **bygg** og **plan**. Du kan tilpasse -disse eller konfigurer din egen gjennom OpenCode-konfigurasjonen. - -Du kan bytte mellom moduser under en økt eller konfigurere dem i konfigurasjonsfilen din. - ---- - -## Innebygd - -OpenCode kommer med to innebygde moduser. - ---- - -### Bygg - -Bygg er **standard**-modusen med alle verktøy aktivert. Dette er standardmodusen for utviklingsarbeid der du trenger full tilgang til filoperasjoner og systemkommandoer. - ---- - -### Plan - -En begrenset modus designet for planlegging og analyse. I planmodus er følgende verktøy deaktivert som standard: - -- `write` - Kan ikke opprette nye filer -- `edit` - Kan ikke endre eksisterende filer, bortsett fra filer som ligger på `.opencode/plans/*.md` for å detaljere selve planen -- `patch` - Kan ikke bruke patcher -- `bash` - Kan ikke utføre shell-kommandoer - -Denne modusen er nyttig når du vil at AI skal analysere kode, foreslå endringer eller lage planer uten å gjøre noen faktiske endringer i kodebasen. - ---- - -## Bytte - -Du kan bytte mellom moduser under en økt ved å bruke _Tab_-tasten. Eller din konfigurerte `switch_mode` hurtigtast. - -Se også: [Formatters](/docs/formatters) for informasjon om kodeformateringskonfigurasjon. - ---- - -## Konfigurer - -Du kan tilpasse de innebygde modusene eller opprette din egen gjennom konfigurasjon. Moduser kan konfigureres på to måter: - -### JSON-konfigurasjon - -Konfigurer moduser i din `opencode.json` konfigurasjonsfil: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown-konfigurasjon - -Du kan også definere moduser ved hjelp av markdown-filer. Plasser dem i: - -- Globalt: `~/.config/opencode/modes/` -- Prosjekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Filnavnet til markdown-filen blir modusnavnet (f.eks. `review.md` oppretter en `review`-modus). - -La oss se på disse konfigurasjonsalternativene i detalj. - ---- - -### Modell - -Bruk `model`-konfigurasjonen for å overstyre standardmodellen for denne modusen. Nyttig for å bruke forskjellige modeller optimalisert for forskjellige oppgaver. For eksempel en raskere modell for planlegging, en mer kapabel modell for implementering. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatur - -Kontroller tilfeldigheten og kreativiteten til AI-ens svar med `temperature`-konfigurasjonen. Lavere verdier gjør svarene mer fokuserte og deterministiske, mens høyere verdier øker kreativiteten og variasjonen. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Temperaturverdier varierer vanligvis fra 0,0 til 1,0: - -- **0.0-0.2**: Veldig fokuserte og deterministiske svar, ideelt for kodeanalyse og planlegging -- **0.3-0.5**: Balanserte svar med litt kreativitet, bra for generelle utviklingsoppgaver -- **0.6-1.0**: Mer kreative og varierte svar, nyttig for idédugnad og utforskning - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Hvis ingen temperatur er spesifisert, bruker OpenCode modellspesifikke standardinnstillinger (vanligvis 0 for de fleste modeller, 0,55 for Qwen-modeller). - ---- - -### Prompt - -Angi en tilpasset systemprompt-fil for denne modusen med `prompt`-konfigurasjonen. Prompt-filen skal inneholde instruksjoner som er spesifikke for modusens formål. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Denne banen er relativ til der konfigurasjonsfilen er plassert. Så dette fungerer for både den globale OpenCode-konfigurasjonen og den prosjektspesifikke konfigurasjonen. - ---- - -### Verktøy - -Kontroller hvilke verktøy som er tilgjengelige i denne modusen med `tools`-konfigurasjonen. Du kan aktivere eller deaktivere spesifikke verktøy ved å sette dem til `true` eller `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hvis ingen verktøy er spesifisert, er alle verktøy aktivert som standard. - ---- - -#### Tilgjengelige verktøy - -Her er alle verktøyene som kan kontrolleres gjennom moduskonfigurasjonen. - -| Verktøy | Beskrivelse | -| ----------- | --------------------------- | -| `bash` | Utfør shell-kommandoer | -| `edit` | Endre eksisterende filer | -| `write` | Opprett nye filer | -| `read` | Les filinnhold | -| `grep` | Søk i filinnhold | -| `glob` | Finn filer etter mønster | -| `patch` | Bruk patcher på filer | -| `todowrite` | Administrer gjøremålslister | -| `webfetch` | Hent webinnhold | - ---- - -## Egendefinerte moduser - -Du kan opprette dine egne tilpassede moduser ved å legge dem til i konfigurasjonen. Her er eksempler på bruk av begge metodene: - -### Bruke JSON-konfigurasjon - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Bruke markdown-filer - -Opprett modusfiler i `.opencode/modes/` for prosjektspesifikke moduser eller `~/.config/opencode/modes/` for globale moduser: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Bruksområder - -Her er noen vanlige bruksområder for forskjellige moduser. - -- **Bygg-modus**: Fullt utviklingsarbeid med alle verktøy aktivert -- **Plan-modus**: Analyse og planlegging uten å gjøre endringer -- **Review-modus**: Kodegjennomgang med skrivebeskyttet tilgang pluss dokumentasjonsverktøy -- **Debug-modus**: Fokusert på etterforskning med bash- og leseverktøy aktivert -- **Docs-modus**: Dokumentasjonsskriving med filoperasjoner, men ingen systemkommandoer - -Du kan også finne at forskjellige modeller er gode for forskjellige bruksområder. diff --git a/packages/web/src/content/docs/nb/providers.mdx b/packages/web/src/content/docs/nb/providers.mdx index 1fe8812e67..bf276918a9 100644 --- a/packages/web/src/content/docs/nb/providers.mdx +++ b/packages/web/src/content/docs/nb/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel. +1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel. -2. Kjør kommandoen `/connect` og søk etter **Firmware**. +2. Kjør kommandoen `/connect` og søk etter **FrogBot**. ```txt /connect ``` -3. Skriv inn firmware API nøkkelen. +3. Skriv inn frogbot API nøkkelen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pl/cli.mdx b/packages/web/src/content/docs/pl/cli.mdx index e175870cbf..f2e4ddf9b2 100644 --- a/packages/web/src/content/docs/pl/cli.mdx +++ b/packages/web/src/content/docs/pl/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flagi -| Flaga | Skrót | Opis | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--continue` | `-c` | Kontynuuj ostatnią sesję | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | -| `--fork` | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | -| `--prompt` | | Monit do użycia | -| `--model` | `-m` | Model do użycia w formacie dostawca/model | -| `--agent` | | Agent do użycia | -| `--port` | | Port do nasłuchiwania | -| `--hostname` | | Nazwa hosta, do której należy się powiązać | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------- | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | +| {"--prompt"} | | Monit do użycia | +| {"--model"} | `-m` | Model do użycia w formacie dostawca/model | +| {"--agent"} | | Agent do użycia | +| {"--port"} | | Port do nasłuchiwania | +| {"--hostname"} | | Nazwa hosta, do której należy się powiązać | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flagi -| Flaga | Skrót | Opis | -| ----------- | ----- | --------------------------------------- | -| `--dir` | | Katalog roboczy, w którym uruchomić TUI | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Katalog roboczy, w którym uruchomić TUI | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Rozgałęź sesję podczas kontynuowania (użyj z `--continue` lub `--session`) | +| {"--password"} | `-p` | Hasło uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nazwa użytkownika uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_USERNAME` lub `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flagi -| Flaga | Opis | -| --------- | ---------------------------------------- | -| `--event` | Zdarzenie GitHub, które wyzwoliło agenta | -| `--token` | Osobisty token dostępu GitHub | +| Flaga | Opis | +| ------------------------------------- | ---------------------------------------- | +| {"--event"} | Zdarzenie GitHub, które wyzwoliło agenta | +| {"--token"} | Osobisty token dostępu GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flagi -| Flaga | Opis | -| ----------- | ------------------------------------------------------------------------------- | -| `--refresh` | Odśwież pamięć podręczną modeli | -| `--verbose` | Bardziej szczegółowe dane wyjściowe modelu (zawiera metadane, takie jak koszty) | +| Flaga | Opis | +| --------------------------------------- | ------------------------------------------------------------------------------- | +| {"--refresh"} | Odśwież pamięć podręczną modeli | +| {"--verbose"} | Bardziej szczegółowe dane wyjściowe modelu (zawiera metadane, takie jak koszty) | Użyj flagi `--refresh`, aby zaktualizować listę modeli w pamięci podręcznej. Jest to przydatne, gdy dostawca dodał nowe modele, które chcesz zobaczyć w OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flagi -| Flaga | Skrót | Opis | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--command` | | Polecenie do uruchomienia, reszta to argumenty | -| `--continue` | `-c` | Kontynuuj ostatnią sesję | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | -| `--fork` | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | -| `--share` | | Udostępnij sesję po zakończeniu | -| `--model` | `-m` | Model do użycia w formacie dostawca/model | -| `--agent` | | Agent do użycia | -| `--file` | `-f` | Pliki do załączenia do wiadomości | -| `--format` | | Format wyjściowy: `default` (sformatowany) lub `json` (surowy JSON) | -| `--title` | | Tytuł sesji (jeśli nie podano, zostanie wygenerowany z promptu) | -| `--attach` | | Dołącz do działającego serwera OpenCode (np. http://localhost:4096) | -| `--port` | | Port dla serwera lokalnego (domyślnie losowy) | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| {"--command"} | | Polecenie do uruchomienia, reszta to argumenty | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | +| {"--share"} | | Udostępnij sesję po zakończeniu | +| {"--model"} | `-m` | Model do użycia w formacie dostawca/model | +| {"--agent"} | | Agent do użycia | +| {"--file"} | `-f` | Pliki do załączenia do wiadomości | +| {"--format"} | | Format wyjściowy: `default` (sformatowany) lub `json` (surowy JSON) | +| {"--title"} | | Tytuł sesji (jeśli nie podano, zostanie wygenerowany z promptu) | +| {"--attach"} | | Dołącz do działającego serwera OpenCode (np. http://localhost:4096) | +| {"--password"} | `-p` | Hasło uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nazwa użytkownika uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_USERNAME` lub `opencode`) | +| {"--dir"} | | Katalog do uruchomienia lub ścieżka na zdalnym serwerze podczas dołączania | +| {"--variant"} | | Wariant modelu (poziom wnioskowania specyficzny dla dostawcy) | +| {"--thinking"} | | Pokaż bloki myślenia | +| {"--port"} | | Port dla serwera lokalnego (domyślnie losowy) | --- @@ -364,12 +373,12 @@ Uruchamia to serwer HTTP, który zapewnia dostęp do API OpenCode bez interfejsu #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | -| `--mdns` | Włącz wykrywanie mDNS | -| `--cors` | Dodatkowe dozwolone źródła CORS | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | +| {"--mdns"} | Włącz wykrywanie mDNS | +| {"--cors"} | Dodatkowe dozwolone źródła CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flagi -| Flaga | Skrót | Opis | -| ------------- | ----- | ------------------------------------------ | -| `--max-count` | `-n` | Ogranicz do ostatnich N sesji | -| `--format` | | Format wyjściowy: tabela lub json (tabela) | +| Flaga | Skrót | Opis | +| ----------------------------------------- | ----- | ------------------------------------------ | +| {"--max-count"} | `-n` | Ogranicz do ostatnich N sesji | +| {"--format"} | | Format wyjściowy: tabela lub json (tabela) | --- @@ -410,12 +419,12 @@ opencode stats #### Flagi -| Flaga | Opis | -| ----------- | ------------------------------------------------------------------------------------- | -| `--days` | Pokaż statystyki z ostatnich N dni (domyślnie: cały czas) | -| `--tools` | Pokaż użycie poszczególnych narzędzi (domyślnie: wszystkie) | -| `--models` | Pokaż podział na modele (domyślnie ukryty). Podaj liczbę, aby pokazać N najczęstszych | -| `--project` | Filtruj według projektu (domyślnie: wszystkie projekty, pusty ciąg: bieżący projekt) | +| Flaga | Opis | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--days"} | Pokaż statystyki z ostatnich N dni (domyślnie: cały czas) | +| {"--tools"} | Pokaż użycie poszczególnych narzędzi (domyślnie: wszystkie) | +| {"--models"} | Pokaż podział na modele (domyślnie ukryty). Podaj liczbę, aby pokazać N najczęstszych | +| {"--project"} | Filtruj według projektu (domyślnie: wszystkie projekty, pusty ciąg: bieżący projekt) | --- @@ -460,12 +469,12 @@ Uruchamia to serwer HTTP i udostępnia OpenCode przez interfejs przeglądarkowy. #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | -| `--mdns` | Włącz wykrywanie mDNS | -| `--cors` | Dodatkowe dozwolone źródła CORS | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | +| {"--mdns"} | Włącz wykrywanie mDNS | +| {"--cors"} | Dodatkowe dozwolone źródła CORS | --- @@ -481,11 +490,11 @@ Uruchamia serwer ACP, który komunikuje się przez stdin/stdout przy użyciu JSO #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--cwd` | Katalog roboczy | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--cwd"} | Katalog roboczy | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flagi -| Flaga | Skrót | Opis | -| --------------- | ----- | ----------------------------- | -| `--keep-config` | `-c` | Zachowaj pliki konfiguracyjne | -| `--keep-data` | `-d` | Zachowaj dane sesji i migawki | -| `--dry-run` | | Pokaż co zostanie usunięte | -| `--force` | `-f` | Pomiń monity o potwierdzenie | +| Flaga | Skrót | Opis | +| ------------------------------------------- | ----- | ----------------------------- | +| {"--keep-config"} | `-c` | Zachowaj pliki konfiguracyjne | +| {"--keep-data"} | `-d` | Zachowaj dane sesji i migawki | +| {"--dry-run"} | | Pokaż co zostanie usunięte | +| {"--force"} | `-f` | Pomiń monity o potwierdzenie | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flagi -| Flaga | Skrót | Opis | -| ---------- | ----- | --------------------------------------------------- | -| `--method` | `-m` | Wymuś metodę instalacji: curl, npm, pnpm, bun, brew | +| Flaga | Skrót | Opis | +| -------------------------------------- | ----- | --------------------------------------------------- | +| {"--method"} | `-m` | Wymuś metodę instalacji: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 Interfejs CLI OpenCode przyjmuje następujące flagi globalne dla każdego polecenia. -| Flaga | Skrót | Opis | -| -------------- | ----- | ------------------------------------------- | -| `--help` | `-h` | Wyświetl pomoc | -| `--version` | `-v` | Wydrukuj numer wersji | -| `--print-logs` | | Drukuj logi na stderr | -| `--log-level` | | Poziom logowania (DEBUG, INFO, WARN, ERROR) | +| Flaga | Skrót | Opis | +| ------------------------------------------ | ----- | ------------------------------------------- | +| {"--help"} | `-h` | Wyświetl pomoc | +| {"--version"} | `-v` | Wydrukuj numer wersji | +| {"--print-logs"} | | Drukuj logi na stderr | +| {"--log-level"} | | Poziom logowania (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/pl/modes.mdx b/packages/web/src/content/docs/pl/modes.mdx deleted file mode 100644 index 8d7c2568d3..0000000000 --- a/packages/web/src/content/docs/pl/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Tryby -description: Różne tryby dla różnych zastosowań. ---- - -:::caution -Tryby są teraz konfigurowane za pomocą opcji `agent` w konfiguracji opencode. -Opcja `mode` jest obecnie przestarzała. [Dowiedz się więcej](/docs/agents). -::: - -Tryb udostępniania możliwości stosowania, narzędzie i podpowiedzi do różnych zastosowań. - -Posiadanie dwa tryby: **Build** i **Plan**. Można dostosować -te lub skonfiguruj własne za pomocą konfiguracji opencode. - -Można przełączać się między trybami podczas sesji lub konfigurować je w pliku konfiguracyjnym. - ---- - -## Wbudowane - -opencode ma dwa puste tryby. - ---- - -### Build - -Build jest trybem **domyślnym** z dostępnymi narzędziami. Jest to standardowy tryb pracy programistycznej, który jest dostępny z pełnym dostępem do operacji na plikach i oryginalnych systemach systemowych. - ---- - -### Plan - -Tryb ograniczony do analizy. W urządzeniu planowym narzędzia są przydatne: - -- `write` - Nie można stworzyć nowych plików -- `edit` — Nie można zastosować naruszenia plików, z naruszeniem praw autorskich w `.opencode/plans/*.md` w celu uszczegółowienia samego planu -- `patch` - Nie można zastosować poprawek -- `bash` - Nie można wykonać poleceń shell - -Ten tryb jest alternatywny, gdy chcesz, aby sztuczna inteligencja analizowała kod, sugerowała zmianę lub tworzyła projekty bez źródła zewnętrznego, które stanowi bazę kodu. - ---- - -## Przełączanie - -Możesz przełączać się między trybami podczas sesji za pomocą klawisza _Tab_. Lub skrót klawiszowy `switch_mode`. - -Zobacz także: [Formaterzy](/docs/formatters), aby uzyskać informacje na temat konfiguracji formatowania kodu. - ---- - -## Skonfiguruj - -Możliwość dostosowania alternatywnego trybu lub konfiguracji poprzez własną konfigurację. Tryb można skonfigurować na dwa systemy: - -### Konfiguracja JSON - -Skonfiguruj tryb w pliku konfiguracyjnym `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Konfiguracja Markdown - -Można także definiować tryby za pomocą plików przecen. Trzymaj je w: - -- Globalnie: `~/.config/opencode/modes/` -- Projekt: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Nazwa pliku przecen staje się kluczem trybu (np. `review.md` tworzy tryb `review`). - -Przyjrzyjmy się szczegółowo tym opcjom konfiguracji. - ---- - -### Model - -Zastosowanie konstrukcji `model`, aby zastosować domyślny model dla tego trybu. Przydatne przy wykorzystaniu różnych modeli dostępnych w różnych zadaniach. Na przykład zastosowanie modelu, skuteczniejsze wykonanie modelu. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Kontroluj losowość i kreatywność reakcji AI za pomocą konstrukcji `temperature`. Niższe wartości, że odpowiedzi są bardziej skupione i deterministyczne, podczas gdy najwyższa wartość jest innowacyjna i złożona. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Wartości temperatury zazwyczaj wahają się od 0,0 do 1,0: - -- **0,0-0,2**: Bardzo skoncentrowane i deterministyczne odpowiedzi, idealne do analizy i kodu źródłowego -- **0,3-0,5**: Zrównoważona odpowiedź z chwilą powstania, dobre do ogólnych zadań rozwojowych -- **0,6–1,0**: Bardziej kreatywne i odpowiedzi, rozstrzygnięte podczas burzy mózgów i eksploracji - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Jeśli nie ma wpływu na temperaturę, opencode stosuje się narzędzia badawcze dla modelu (zwykle 0 dla największych modeli, 0,55 dla modeli Qwen). - ---- - -### Prompt (Monit) - -niestandardowy plik podpowiedzi systemowych dla tej procedury za pomocą konfiguracji `prompt`. Plik informacyjny powinien zawierać instrukcje dotyczące przeznaczenia trybu. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Ścieżka ta zależy od miejsca, w którym znajduje się plik konfiguracyjny. Więc to dla działa -zarówno globalna opencode, jak i specjalna dla projektu. - ---- - -### Narzędzia - -Kontroluj, które narzędzia są dostępne w tym urządzeniu, za pomocą konfiguracji `tools`. Można włączyć lub dostępne narzędzie, ustawiając je na `true` lub `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Jeśli nie ma żadnych narzędzi, wszystkie narzędzia są wyłączone. - ---- - -#### Dostępne narzędzia - -Oto wszystkie narzędzia, które można sterować za pomocą konfiguracji trybów. - -| Narzędzie | Opis | -| ----------- | ------------------------------------- | -| `bash` | Wykonaj polecenia shell | -| `edit` | Modyfikuj istniejące pliki | -| `write` | Utwórz nowe pliki | -| `read` | Przeczytaj zawartość pliku | -| `grep` | Wyszukaj zawartość pliku | -| `glob` | Znajdź pliki według wzorca | -| `patch` | Zastosuj poprawki do plików | -| `todowrite` | Zarządzaj listami rzeczy do wykonania | -| `webfetch` | Pobierz zawartość internetową | - ---- - -## Tryby niestandardowe - -Możesz stworzyć własny tryb prywatny, dodając je do konfiguracji. Oto zastosowanie obu rozwiązań: - -### Korzystanie z konfiguracji JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Korzystanie z plików Markdown - -Utwórz pliki trybów w `.opencode/modes/` dla trybów zapisanych dla projektu lub `~/.config/opencode/modes/` dla trybów globalnych: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Przypadki użycia - -Oto kilka typowych zastosowań dla różnych trybów. - -- **Build mode**: Pełne prace programistyczne z dostępnymi narzędziami -- **Plan mode**: Analiza i planowanie bez zmian -- **Review mode**: Przegląd kodu z możliwością odczytu i narzędzi do dokumentacji -- **Debug mode**: Koncentruje się na urządzeniu z dostępnymi narzędziami bash i odczytu -- **Docs mode**: Zapisywanie dokumentacji przy użyciu operacji na plikach, ale bez oryginalnych systemów systemowych - -Może się również zdarzyć, że różne modele są dobre w różnych wersjach użycia. diff --git a/packages/web/src/content/docs/pl/providers.mdx b/packages/web/src/content/docs/pl/providers.mdx index deadd07d6a..0e722d5fde 100644 --- a/packages/web/src/content/docs/pl/providers.mdx +++ b/packages/web/src/content/docs/pl/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API. +1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API. -2. Uruchom polecenie `/connect` i wyszukaj **Firmware**. +2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**. ```txt /connect ``` -3. Wprowadź klucz API Firmware. +3. Wprowadź klucz API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 8576ec3562..8410c549f2 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire --- -### Firmware +### FrogBot -1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. +1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key. -2. Run the `/connect` command and search for **Firmware**. +2. Run the `/connect` command and search for **FrogBot**. ```txt /connect ``` -3. Enter your Firmware API key. +3. Enter your FrogBot API key. ```txt ┌ API key @@ -1767,7 +1767,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon ### STACKIT -STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. +STACKIT AI Model Serving provides fully managed sovereign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. 1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project. diff --git a/packages/web/src/content/docs/pt-br/cli.mdx b/packages/web/src/content/docs/pt-br/cli.mdx index 78190b3c5d..889626d417 100644 --- a/packages/web/src/content/docs/pt-br/cli.mdx +++ b/packages/web/src/content/docs/pt-br/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opções -| Flag | Curto | Descrição | -| ------------ | ----- | -------------------------------------------------------------------------- | -| `--continue` | `-c` | Continue a última sessão | -| `--session` | `-s` | ID da sessão para continuar | -| `--fork` | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | -| `--prompt` | | Prompt a ser usado | -| `--model` | `-m` | Modelo a ser usado na forma de provider/model | -| `--agent` | | Agente a ser usado | -| `--port` | | Porta para escutar | -| `--hostname` | | Nome do host para escutar | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continue a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | +| {"--prompt"} | | Prompt a ser usado | +| {"--model"} | `-m` | Modelo a ser usado na forma de provider/model | +| {"--agent"} | | Agente a ser usado | +| {"--port"} | | Porta para escutar | +| {"--hostname"} | | Nome do host para escutar | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opções -| Flag | Curto | Descrição | -| ----------- | ----- | ---------------------------------------- | -| `--dir` | | Diretório de trabalho para iniciar o TUI | -| `--session` | `-s` | ID da sessão para continuar | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | --------------------------------------------------------------------------------- | +| {"--dir"} | | Diretório de trabalho para iniciar o TUI | +| {"--continue"} | `-c` | Continuar a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Bifurcar a sessão ao continuar (use com `--continue` ou `--session`) | +| {"--password"} | `-p` | Senha de autenticação básica (padrão: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuário de autenticação básica (padrão: `OPENCODE_SERVER_USERNAME` ou `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opções -| Flag | Descrição | -| --------- | ------------------------------------------------ | -| `--event` | Evento simulado do GitHub para executar o agente | -| `--token` | Token de acesso pessoal do GitHub | +| Flag | Descrição | +| ------------------------------------- | ------------------------------------------------ | +| {"--event"} | Evento simulado do GitHub para executar o agente | +| {"--token"} | Token de acesso pessoal do GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Opções -| Flag | Descrição | -| ----------- | --------------------------------------------------------------------- | -| `--refresh` | Atualiza o cache de modelos a partir do models.dev | -| `--verbose` | Use uma saída de modelo mais detalhada (inclui metadados como custos) | +| Flag | Descrição | +| --------------------------------------- | --------------------------------------------------------------------- | +| {"--refresh"} | Atualiza o cache de modelos a partir do models.dev | +| {"--verbose"} | Use uma saída de modelo mais detalhada (inclui metadados como custos) | Use a flag `--refresh` para atualizar a lista de modelos em cache. Isso é útil quando novos modelos foram adicionados a um provedor e você deseja vê-los no opencode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explique async/await em JavaScript" #### Opções -| Flag | Curto | Descrição | -| ------------ | ----- | ----------------------------------------------------------------------------- | -| `--command` | | O comando a ser executado, use mensagem para argumentos | -| `--continue` | `-c` | Continue a última sessão | -| `--session` | `-s` | ID da sessão para continuar | -| `--fork` | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | -| `--share` | | Compartilhe a sessão | -| `--model` | `-m` | Modelo a ser usado na forma de provider/model | -| `--agent` | | Agente a ser usado | -| `--file` | `-f` | Arquivo(s) a serem anexados à mensagem | -| `--format` | | Formato: padrão (formatado) ou json (eventos JSON brutos) | -| `--title` | | Título para a sessão (usa o prompt truncado se nenhum valor for fornecido) | -| `--attach` | | Anexe a um servidor opencode em execução (por exemplo, http://localhost:4096) | -| `--port` | | Porta para o servidor local (padrão para porta aleatória) | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | --------------------------------------------------------------------------------- | +| {"--command"} | | O comando a ser executado, use mensagem para argumentos | +| {"--continue"} | `-c` | Continue a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | +| {"--share"} | | Compartilhe a sessão | +| {"--model"} | `-m` | Modelo a ser usado na forma de provider/model | +| {"--agent"} | | Agente a ser usado | +| {"--file"} | `-f` | Arquivo(s) a serem anexados à mensagem | +| {"--format"} | | Formato: padrão (formatado) ou json (eventos JSON brutos) | +| {"--title"} | | Título para a sessão (usa o prompt truncado se nenhum valor for fornecido) | +| {"--attach"} | | Anexe a um servidor opencode em execução (por exemplo, http://localhost:4096) | +| {"--password"} | `-p` | Senha de autenticação básica (padrão: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuário de autenticação básica (padrão: `OPENCODE_SERVER_USERNAME` ou `opencode`) | +| {"--dir"} | | Diretório de execução, ou caminho no servidor remoto ao anexar | +| {"--variant"} | | Variante do modelo (nível de raciocínio específico do provedor) | +| {"--thinking"} | | Mostrar blocos de pensamento | +| {"--port"} | | Porta para o servidor local (padrão para porta aleatória) | --- @@ -364,12 +373,12 @@ Isso inicia um servidor HTTP que fornece acesso à funcionalidade do opencode se #### Opções -| Flag | Descrição | -| ------------ | ----------------------------------------------------- | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | -| `--mdns` | Habilitar descoberta mDNS | -| `--cors` | Origem(ns) de navegador adicionais para permitir CORS | +| Flag | Descrição | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | +| {"--mdns"} | Habilitar descoberta mDNS | +| {"--cors"} | Origem(ns) de navegador adicionais para permitir CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Opções -| Flag | Curto | Descrição | -| ------------- | ----- | ----------------------------------------- | -| `--max-count` | `-n` | Limitar às N sessões mais recentes | -| `--format` | | Formato de saída: tabela ou json (tabela) | +| Flag | Curto | Descrição | +| ----------------------------------------- | ----- | ----------------------------------------- | +| {"--max-count"} | `-n` | Limitar às N sessões mais recentes | +| {"--format"} | | Formato de saída: tabela ou json (tabela) | --- @@ -410,12 +419,12 @@ opencode stats #### Opções -| Flag | Descrição | -| ----------- | ---------------------------------------------------------------------------------------------------- | -| `--days` | Mostre estatísticas dos últimos N dias (todo o tempo) | -| `--tools` | Número de ferramentas a serem mostradas (todas) | -| `--models` | Mostre a divisão do uso de modelos (oculto por padrão). Passe um número para mostrar os N principais | -| `--project` | Filtrar por projeto (todos os projetos, string vazia: projeto atual) | +| Flag | Descrição | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| {"--days"} | Mostre estatísticas dos últimos N dias (todo o tempo) | +| {"--tools"} | Número de ferramentas a serem mostradas (todas) | +| {"--models"} | Mostre a divisão do uso de modelos (oculto por padrão). Passe um número para mostrar os N principais | +| {"--project"} | Filtrar por projeto (todos os projetos, string vazia: projeto atual) | --- @@ -460,12 +469,12 @@ Isso inicia um servidor HTTP e abre um navegador para acessar o opencode atravé #### Opções -| Flag | Descrição | -| ------------ | ----------------------------------------------------- | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | -| `--mdns` | Habilitar descoberta mDNS | -| `--cors` | Origem(ns) de navegador adicionais para permitir CORS | +| Flag | Descrição | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | +| {"--mdns"} | Habilitar descoberta mDNS | +| {"--cors"} | Origem(ns) de navegador adicionais para permitir CORS | --- @@ -481,11 +490,11 @@ Este comando inicia um servidor ACP que se comunica via stdin/stdout usando nd-J #### Opções -| Flag | Descrição | -| ------------ | ------------------------- | -| `--cwd` | Diretório de trabalho | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | +| Flag | Descrição | +| ---------------------------------------- | ------------------------- | +| {"--cwd"} | Diretório de trabalho | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | --- @@ -499,12 +508,12 @@ opencode uninstall #### Opções -| Flag | Curto | Descrição | -| --------------- | ----- | ---------------------------------------- | -| `--keep-config` | `-c` | Manter arquivos de configuração | -| `--keep-data` | `-d` | Manter dados de sessão e snapshots | -| `--dry-run` | | Mostrar o que seria removido sem remover | -| `--force` | `-f` | Pular prompts de confirmação | +| Flag | Curto | Descrição | +| ------------------------------------------- | ----- | ---------------------------------------- | +| {"--keep-config"} | `-c` | Manter arquivos de configuração | +| {"--keep-data"} | `-d` | Manter dados de sessão e snapshots | +| {"--dry-run"} | | Mostrar o que seria removido sem remover | +| {"--force"} | `-f` | Pular prompts de confirmação | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Opções -| Flag | Curto | Descrição | -| ---------- | ----- | ---------------------------------------------------------------- | -| `--method` | `-m` | O método de instalação que foi usado; curl, npm, pnpm, bun, brew | +| Flag | Curto | Descrição | +| -------------------------------------- | ----- | ---------------------------------------------------------------- | +| {"--method"} | `-m` | O método de instalação que foi usado; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 A CLI do opencode aceita as seguintes flags globais. -| Flag | Curto | Descrição | -| -------------- | ----- | --------------------------------------- | -| `--help` | `-h` | Exibir ajuda | -| `--version` | `-v` | Imprimir número da versão | -| `--print-logs` | | Imprimir logs no stderr | -| `--log-level` | | Nível de log (DEBUG, INFO, WARN, ERROR) | +| Flag | Curto | Descrição | +| ------------------------------------------ | ----- | --------------------------------------- | +| {"--help"} | `-h` | Exibir ajuda | +| {"--version"} | `-v` | Imprimir número da versão | +| {"--print-logs"} | | Imprimir logs no stderr | +| {"--log-level"} | | Nível de log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/pt-br/modes.mdx b/packages/web/src/content/docs/pt-br/modes.mdx deleted file mode 100644 index b53fb4fcb4..0000000000 --- a/packages/web/src/content/docs/pt-br/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: Modos -description: Modos diferentes para diferentes casos de uso. ---- - -:::caution -Os modos agora são configurados através da opção `agent` na configuração do opencode. A opção `mode` agora está obsoleta. [Saiba mais](/docs/agents). -::: - -Os modos no opencode permitem que você personalize o comportamento, as ferramentas e os prompts para diferentes casos de uso. - -Ele vem com dois modos integrados: **build** e **plan**. Você pode personalizar esses ou configurar os seus próprios através da configuração do opencode. - -Você pode alternar entre os modos durante uma sessão ou configurá-los no seu arquivo de configuração. - ---- - -## Integrados - -O opencode vem com dois modos integrados. - ---- - -### build - -Build é o modo **padrão** com todas as ferramentas habilitadas. Este é o modo padrão para trabalho de desenvolvimento onde você precisa de acesso total a operações de arquivos e comandos do sistema. - ---- - -### plan - -Um modo restrito projetado para planejamento e análise. No modo plan, as seguintes ferramentas estão desativadas por padrão: - -- `write` - Não pode criar novos arquivos -- `edit` - Não pode modificar arquivos existentes, exceto para arquivos localizados em `.opencode/plans/*.md` para detalhar o plano em si -- `patch` - Não pode aplicar patches -- `bash` - Não pode executar comandos de shell - -Este modo é útil quando você deseja que a IA analise o código, sugira alterações ou crie planos sem fazer modificações reais em sua base de código. - ---- - -## Alternando - -Você pode alternar entre modos durante uma sessão usando a tecla _Tab_. Ou sua tecla de atalho configurada `switch_mode`. - -Veja também: [Formatadores](/docs/formatters) para informações sobre configuração de formatação de código. - ---- - -## Configuração - -Você pode personalizar os modos integrados ou criar os seus próprios através da configuração. Os modos podem ser configurados de duas maneiras: - -### Configuração JSON - -Configure os modos no seu arquivo de configuração `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Configuração Markdown - -Você também pode definir modos usando arquivos markdown. Coloque-os em: - -- Global: `~/.config/opencode/modes/` -- Projeto: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -O nome do arquivo markdown se torna o nome do modo (por exemplo, `review.md` cria um modo `review`). - -Vamos analisar essas opções de configuração em detalhes. - ---- - -### Modelo - -Use a configuração `model` para substituir o modelo padrão para este modo. Útil para usar diferentes modelos otimizados para diferentes tarefas. Por exemplo, um modelo mais rápido para planejamento, um modelo mais capaz para implementação. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperatura - -Controle a aleatoriedade e a criatividade das respostas da IA com a configuração `temperature`. Valores mais baixos tornam as respostas mais focadas e determinísticas, enquanto valores mais altos aumentam a criatividade e a variabilidade. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Os valores de temperatura geralmente variam de 0.0 a 1.0: - -- **0.0-0.2**: Respostas muito focadas e determinísticas, ideais para análise de código e planejamento -- **0.3-0.5**: Respostas equilibradas com alguma criatividade, boas para tarefas de desenvolvimento geral -- **0.6-1.0**: Respostas mais criativas e variadas, úteis para brainstorming e exploração - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Se nenhuma temperatura for especificada, o opencode usa padrões específicos do modelo (geralmente 0 para a maioria dos modelos, 0.55 para modelos Qwen). - ---- - -### Prompt - -Especifique um arquivo de prompt do sistema personalizado para este modo com a configuração `prompt`. O arquivo de prompt deve conter instruções específicas para o propósito do modo. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Este caminho é relativo a onde o arquivo de configuração está localizado. Portanto, isso funciona tanto para a configuração global do opencode quanto para a configuração específica do projeto. - ---- - -### Ferramentas - -Controle quais ferramentas estão disponíveis neste modo com a configuração `tools`. Você pode habilitar ou desabilitar ferramentas específicas definindo-as como `true` ou `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Se nenhuma ferramenta for especificada, todas as ferramentas estão habilitadas por padrão. - ---- - -#### Ferramentas disponíveis - -Aqui estão todas as ferramentas que podem ser controladas através da configuração do modo. - -| Ferramenta | Descrição | -| ----------- | ------------------------------- | -| `bash` | Executar comandos de shell | -| `edit` | Modificar arquivos existentes | -| `write` | Criar novos arquivos | -| `read` | Ler conteúdos de arquivos | -| `grep` | Pesquisar conteúdos de arquivos | -| `glob` | Encontrar arquivos por padrão | -| `patch` | Aplicar patches a arquivos | -| `todowrite` | Gerenciar listas de tarefas | -| `webfetch` | Buscar conteúdo da web | - ---- - -## Modos personalizados - -Você pode criar seus próprios modos personalizados adicionando-os à configuração. Aqui estão exemplos usando ambas as abordagens: - -### Usando configuração JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Usando arquivos markdown - -Crie arquivos de modo em `.opencode/modes/` para modos específicos do projeto ou `~/.config/opencode/modes/` para modos globais: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Casos de uso - -Aqui estão alguns casos de uso comuns para diferentes modos. - -- **Modo Build**: Trabalho de desenvolvimento completo com todas as ferramentas habilitadas -- **Modo Plan**: Análise e planejamento sem fazer alterações -- **Modo Review**: Revisão de código com acesso somente leitura e ferramentas de documentação -- **Modo Debug**: Focado em investigação com ferramentas bash e de leitura habilitadas -- **Modo Docs**: Redação de documentação com operações de arquivo, mas sem comandos do sistema - -Você também pode descobrir que diferentes modelos são bons para diferentes casos de uso. diff --git a/packages/web/src/content/docs/pt-br/providers.mdx b/packages/web/src/content/docs/pt-br/providers.mdx index 50f841cf36..174bc1679b 100644 --- a/packages/web/src/content/docs/pt-br/providers.mdx +++ b/packages/web/src/content/docs/pt-br/providers.mdx @@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API. +1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API. -2. Execute o comando `/connect` e procure por **Firmware**. +2. Execute o comando `/connect` e procure por **FrogBot**. ```txt /connect ``` -3. Insira sua chave da API Firmware. +3. Insira sua chave da API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ru/cli.mdx b/packages/web/src/content/docs/ru/cli.mdx index f5aeee256f..5f52f3d7f0 100644 --- a/packages/web/src/content/docs/ru/cli.mdx +++ b/packages/web/src/content/docs/ru/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Флаги -| Флаг | Короткий | Описание | -| ------------ | -------- | ----------------------------------------------------------------------------- | -| `--continue` | `-c` | Продолжить последний сеанс | -| `--session` | `-s` | Идентификатор сеанса для продолжения | -| `--fork` | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | -| `--prompt` | | Промпт для использования | -| `--model` | `-m` | Модель для использования в виде поставщика/модели. | -| `--agent` | | Агент для использования | -| `--port` | | Порт для прослушивания | -| `--hostname` | | Имя хоста для прослушивания | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ----------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | +| {"--prompt"} | | Промпт для использования | +| {"--model"} | `-m` | Модель для использования в виде поставщика/модели. | +| {"--agent"} | | Агент для использования | +| {"--port"} | | Порт для прослушивания | +| {"--hostname"} | | Имя хоста для прослушивания | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Флаги -| Флаг | Короткий | Описание | -| ----------- | -------- | ------------------------------------ | -| `--dir` | | Рабочий каталог для запуска TUI | -| `--session` | `-s` | Идентификатор сеанса для продолжения | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| {"--dir"} | | Рабочий каталог для запуска TUI | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Создать ответвление сеанса при продолжении (используйте с `--continue` или `--session`) | +| {"--password"} | `-p` | Пароль базовой аутентификации (по умолчанию `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Имя пользователя базовой аутентификации (по умолчанию `OPENCODE_SERVER_USERNAME` или `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Флаги -| Флаг | Описание | -| --------- | --------------------------------------------- | -| `--event` | Имитирующее событие GitHub для запуска агента | -| `--token` | Токен личного доступа GitHub | +| Флаг | Описание | +| ------------------------------------- | --------------------------------------------- | +| {"--event"} | Имитирующее событие GitHub для запуска агента | +| {"--token"} | Токен личного доступа GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Флаги -| Флаг | Описание | -| ----------- | --------------------------------------------------------------------------------- | -| `--refresh` | Обновите кеш моделей на сайте models.dev. | -| `--verbose` | Используйте более подробный вывод модели (включая метаданные, такие как затраты). | +| Флаг | Описание | +| --------------------------------------- | --------------------------------------------------------------------------------- | +| {"--refresh"} | Обновите кеш моделей на сайте models.dev. | +| {"--verbose"} | Используйте более подробный вывод модели (включая метаданные, такие как затраты). | Используйте флаг `--refresh` для обновления списка кэшированных моделей. Это полезно, когда к поставщику добавлены новые модели и вы хотите увидеть их в opencode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Флаги -| Флаг | Короткий | Описание | -| ------------ | -------- | -------------------------------------------------------------------------------- | -| `--command` | | Команда для запуска, используйте сообщение для аргументов | -| `--continue` | `-c` | Продолжить последний сеанс | -| `--session` | `-s` | Идентификатор сеанса для продолжения | -| `--fork` | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | -| `--share` | | Поделиться сеансом | -| `--model` | `-m` | Модель для использования в виде поставщика/модели. | -| `--agent` | | Агент для использования | -| `--file` | `-f` | Файл(ы) для прикрепления к сообщению | -| `--format` | | Формат: по умолчанию (отформатированный) или json (необработанные события JSON). | -| `--title` | | Название сеанса (использует усеченное приглашение, если значение не указано) | -| `--attach` | | Подключитесь к работающему серверу opencode (например, http://localhost:4096) | -| `--port` | | Порт локального сервера (по умолчанию случайный порт) | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| {"--command"} | | Команда для запуска, используйте сообщение для аргументов | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | +| {"--share"} | | Поделиться сеансом | +| {"--model"} | `-m` | Модель для использования в виде поставщика/модели. | +| {"--agent"} | | Агент для использования | +| {"--file"} | `-f` | Файл(ы) для прикрепления к сообщению | +| {"--format"} | | Формат: по умолчанию (отформатированный) или json (необработанные события JSON). | +| {"--title"} | | Название сеанса (использует усеченное приглашение, если значение не указано) | +| {"--attach"} | | Подключитесь к работающему серверу opencode (например, http://localhost:4096) | +| {"--password"} | `-p` | Пароль базовой аутентификации (по умолчанию `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Имя пользователя базовой аутентификации (по умолчанию `OPENCODE_SERVER_USERNAME` или `opencode`) | +| {"--dir"} | | Каталог для выполнения или путь на удалённом сервере при подключении | +| {"--variant"} | | Вариант модели (уровень рассуждений для провайдера) | +| {"--thinking"} | | Показать блоки размышлений | +| {"--port"} | | Порт локального сервера (по умолчанию случайный порт) | --- @@ -364,12 +373,12 @@ opencode serve #### Флаги -| Флаг | Описание | -| ------------ | ------------------------------------------------------------- | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | -| `--mdns` | Включить обнаружение mDNS | -| `--cors` | Дополнительные источники браузера, позволяющие разрешить CORS | +| Флаг | Описание | +| ---------------------------------------- | ------------------------------------------------------------- | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | +| {"--mdns"} | Включить обнаружение mDNS | +| {"--cors"} | Дополнительные источники браузера, позволяющие разрешить CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Флаги -| Флаг | Короткий | Описание | -| ------------- | -------- | ----------------------------------------- | -| `--max-count` | `-n` | Ограничить N последних сеансов. | -| `--format` | | Формат вывода: таблица или json (таблица) | +| Флаг | Короткий | Описание | +| ----------------------------------------- | -------- | ----------------------------------------- | +| {"--max-count"} | `-n` | Ограничить N последних сеансов. | +| {"--format"} | | Формат вывода: таблица или json (таблица) | --- @@ -410,12 +419,12 @@ opencode stats #### Флаги -| Флаг | Описание | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `--days` | Показать статистику за последние N дней (все время) | -| `--tools` | Количество инструментов для отображения (все) | -| `--models` | Показать разбивку по использованию модели (по умолчанию скрыто). Передайте номер, чтобы показать верхнюю N | -| `--project` | Фильтровать по проекту (все проекты, пустая строка: текущий проект) | +| Флаг | Описание | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Показать статистику за последние N дней (все время) | +| {"--tools"} | Количество инструментов для отображения (все) | +| {"--models"} | Показать разбивку по использованию модели (по умолчанию скрыто). Передайте номер, чтобы показать верхнюю N | +| {"--project"} | Фильтровать по проекту (все проекты, пустая строка: текущий проект) | --- @@ -460,12 +469,12 @@ opencode web #### Флаги -| Флаг | Описание | -| ------------ | ------------------------------------------------------------- | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | -| `--mdns` | Включить обнаружение mDNS | -| `--cors` | Дополнительные источники браузера, позволяющие разрешить CORS | +| Флаг | Описание | +| ---------------------------------------- | ------------------------------------------------------------- | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | +| {"--mdns"} | Включить обнаружение mDNS | +| {"--cors"} | Дополнительные источники браузера, позволяющие разрешить CORS | --- @@ -481,11 +490,11 @@ opencode acp #### Флаги -| Флаг | Описание | -| ------------ | --------------------------- | -| `--cwd` | Рабочий каталог | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | +| Флаг | Описание | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Рабочий каталог | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | --- @@ -499,12 +508,12 @@ opencode uninstall #### Флаги -| Флаг | Короткий | Описание | -| --------------- | -------- | ------------------------------------------ | -| `--keep-config` | `-c` | Сохраняйте файлы конфигурации | -| `--keep-data` | `-d` | Храните данные сеанса и снимки | -| `--dry-run` | | Покажите, что было бы удалено без удаления | -| `--force` | `-f` | Пропустить запросы подтверждения | +| Флаг | Короткий | Описание | +| ------------------------------------------- | -------- | ------------------------------------------ | +| {"--keep-config"} | `-c` | Сохраняйте файлы конфигурации | +| {"--keep-data"} | `-d` | Храните данные сеанса и снимки | +| {"--dry-run"} | | Покажите, что было бы удалено без удаления | +| {"--force"} | `-f` | Пропустить запросы подтверждения | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Флаги -| Флаг | Короткий | Описание | -| ---------- | -------- | --------------------------------------------------------- | -| `--method` | `-m` | Используемый метод установки: local, npm, pnpm, bun, brew | +| Флаг | Короткий | Описание | +| -------------------------------------- | -------- | --------------------------------------------------------- | +| {"--method"} | `-m` | Используемый метод установки: local, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 CLI opencode принимает следующие глобальные флаги. -| Флаг | Короткий | Описание | -| -------------- | -------- | ------------------------------------------ | -| `--help` | `-h` | Отобразить справку | -| `--version` | `-v` | Распечатать номер версии | -| `--print-logs` | | Печать журналов в stderr | -| `--log-level` | | Уровень журнала (DEBUG, INFO, WARN, ERROR) | +| Флаг | Короткий | Описание | +| ------------------------------------------ | -------- | ------------------------------------------ | +| {"--help"} | `-h` | Отобразить справку | +| {"--version"} | `-v` | Распечатать номер версии | +| {"--print-logs"} | | Печать журналов в stderr | +| {"--log-level"} | | Уровень журнала (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ru/modes.mdx b/packages/web/src/content/docs/ru/modes.mdx deleted file mode 100644 index e63c91ace4..0000000000 --- a/packages/web/src/content/docs/ru/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Режимы -description: Различные режимы для разных случаев использования. ---- - -:::caution -Режимы теперь настраиваются с помощью опции `agent` в конфигурации opencode. -Опция `mode` устарела. [Подробнее ](/docs/agents). -::: - -Режимы в opencode позволяют настраивать поведение, инструменты и подсказки для различных вариантов использования. - -Он имеет два встроенных режима: **сборка** и **планирование**. Вы можете настроить -эти или настройте свои собственные через конфигурацию opencode. - -Вы можете переключаться между режимами во время сеанса или настраивать их в своем файле конфигурации. - ---- - -## Встроенный - -opencode имеет два встроенных режима. - ---- - -### Build - -Build — это режим **по умолчанию** со всеми включенными инструментами. Это стандартный режим разработки, в котором вам необходим полный доступ к файловым операциям и системным командам. - ---- - -### Plan - -Ограниченный режим, предназначенный для планирования и анализа. В режиме Plan по умолчанию отключены следующие инструменты: - -- `write` – невозможно создавать новые файлы. -- `edit` – невозможно изменить существующие файлы, за исключением файлов, расположенных по адресу `.opencode/plans/*.md`, для детализации самого плана. -- `patch` – невозможно применить исправления. -- `bash` — невозможно выполнить shell-команды. - -Этот режим полезен, если вы хотите, чтобы ИИ анализировал код, предлагал изменения или создавал планы без внесения каких-либо фактических изменений в вашу кодовую базу. - ---- - -## Переключение - -Переключаться между режимами можно во время сеанса с помощью клавиши _Tab_. Или настроенную вами привязку клавиш `switch_mode`. - -См. также: [Formatters](/docs/formatters) для получения информации о конфигурации форматирования кода. - ---- - -## Настроить - -Вы можете настроить встроенные режимы или создать свои собственные посредством настройки. Режимы можно настроить двумя способами: - -### Конфигурация JSON - -Настройте режимы в файле конфигурации `opencode.json`: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Конфигурация Markdown - -Вы также можете определить режимы, используя файлы Markdown. Поместите их в: - -- Глобальный: `~/.config/opencode/modes/` -- Проект: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Имя Markdown файла становится именем режима (например, `review.md` создает режим `review`). - -Давайте рассмотрим эти параметры конфигурации подробно. - ---- - -### Модель - -Используйте конфигурацию `model`, чтобы переопределить модель по умолчанию для этого режима. Полезно для использования разных моделей, оптимизированных под разные задачи. Например, более быстрая модель планирования и более эффективная модель реализации. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Температура - -Управляйте случайностью и креативностью ответов ИИ с помощью конфигурации `temperature`. Более низкие значения делают ответы более целенаправленными и детерминированными, а более высокие значения повышают креативность и вариативность. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Значения температуры обычно находятся в диапазоне от 0,0 до 1,0: - -- **0,0–0,2**: очень целенаправленные и детерминированные ответы, идеальные для анализа кода и планирования. -- **0,3–0,5**: сбалансированные ответы с некоторой креативностью, подходят для общих задач развития. -- **0,6–1,0**: более творческие и разнообразные ответы, полезные для мозгового штурма и исследования. - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Если температура не указана, opencode использует значения по умолчанию для конкретной модели (обычно 0 для большинства моделей, 0,55 для моделей Qwen). - ---- - -### Промпт - -Укажите собственный файл системных подсказок для этого режима с помощью конфигурации `prompt`. Файл подсказки должен содержать инструкции, специфичные для целей режима. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Этот путь указан относительно того, где находится файл конфигурации. Так что это работает для -как глобальная конфигурация opencode, так и конфигурация конкретного проекта. - ---- - -### Инструменты - -Контролируйте, какие инструменты доступны в этом режиме, с помощью конфигурации `tools`. Вы можете включить или отключить определенные инструменты, установив для них значение `true` или `false`. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Если инструменты не указаны, все инструменты включены по умолчанию. - ---- - -#### Доступные инструменты - -Вот всеми инструментами можно управлять через конфигурацию режима. - -| Инструмент | Описание | -| ----------- | ---------------------- | -| `bash` | Execute shell commands | -| `edit` | Modify existing files | -| `write` | Create new files | -| `read` | Read file contents | -| `grep` | Search file contents | -| `glob` | Find files by pattern | -| `patch` | Apply patches to files | -| `todowrite` | Manage todo lists | -| `webfetch` | Fetch web content | - ---- - -## Пользовательские режимы - -Вы можете создавать свои собственные режимы, добавляя их в конфигурацию. Вот примеры использования обоих подходов: - -### Использование конфигурации JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Использование файлов Markdown - -Создайте файлы режимов в `.opencode/modes/` для режимов, специфичных для проекта, или в `~/.config/opencode/modes/` для глобальных режимов: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Варианты использования - -Вот несколько распространенных случаев использования различных режимов. - -- **Режим сборки**: полная работа по разработке со всеми включенными инструментами. -- **Режим планирования**: анализ и планирование без внесения изменений. -- **Режим проверки**: проверка кода с доступом только для чтения и инструментами документирования. -- **Режим отладки**: сосредоточен на исследовании с включенными инструментами bash и чтения. -- **Режим документации**: запись документации с использованием файловых операций, но без системных команд. - -Вы также можете обнаружить, что разные модели подходят для разных случаев использования. diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx index f5868ceaa0..39aae9e096 100644 --- a/packages/web/src/content/docs/ru/providers.mdx +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к --- -### Firmware +### FrogBot -1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API. +1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API. -2. Запустите команду `/connect` и найдите **Firmware**. +2. Запустите команду `/connect` и найдите **FrogBot**. ```txt /connect ``` -3. Введите ключ API Firmware. +3. Введите ключ API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/th/cli.mdx b/packages/web/src/content/docs/th/cli.mdx index 4b2db9d988..d987228464 100644 --- a/packages/web/src/content/docs/th/cli.mdx +++ b/packages/web/src/content/docs/th/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------ | ---- | ---------------------------------------------------------- | -| `--continue` | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | -| `--fork` | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | -| `--prompt` | | พรอมต์เริ่มต้นที่จะใช้ | -| `--model` | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | -| `--agent` | | เอเจนต์ที่จะใช้ | -| `--port` | | พอร์ตที่จะฟัง | -| `--hostname` | | ชื่อโฮสต์ที่จะฟัง | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ---------------------------------------------------------- | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--prompt"} | | พรอมต์เริ่มต้นที่จะใช้ | +| {"--model"} | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | +| {"--agent"} | | เอเจนต์ที่จะใช้ | +| {"--port"} | | พอร์ตที่จะฟัง | +| {"--hostname"} | | ชื่อโฮสต์ที่จะฟัง | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ----------- | ---- | -------------------------------------- | -| `--dir` | | ไดเร็กทอรีการทำงานเพื่อเริ่มต้น TUI ใน | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| {"--dir"} | | ไดเร็กทอรีการทำงานเพื่อเริ่มต้น TUI ใน | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อดำเนินการต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--password"} | `-p` | รหัสผ่านการยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | ชื่อผู้ใช้การยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_USERNAME` หรือ `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### แฟล็ก -| แฟล็ก | คำอธิบาย | -| --------- | -------------------------------------- | -| `--event` | เหตุการณ์ GitHub เพื่อทริกเกอร์เอเจนต์ | -| `--token` | GitHub token | +| แฟล็ก | คำอธิบาย | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | เหตุการณ์ GitHub เพื่อทริกเกอร์เอเจนต์ | +| {"--token"} | GitHub token | --- @@ -296,11 +300,11 @@ opencode models anthropic #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ----------- | ------------------------------------------------------ | -| `--refresh` | รีเฟรชแคชโมเดลจาก models.dev | -| `--verbose` | แสดงรายละเอียดโมเดลเพิ่มเติม (รวมข้อมูลเมตาเช่นต้นทุน) | -| `--json` | แสดงผลลัพธ์เป็น JSON | +| แฟล็ก | คำอธิบาย | +| --------------------------------------- | ------------------------------------------------------ | +| {"--refresh"} | รีเฟรชแคชโมเดลจาก models.dev | +| {"--verbose"} | แสดงรายละเอียดโมเดลเพิ่มเติม (รวมข้อมูลเมตาเช่นต้นทุน) | +| {"--json"} | แสดงผลลัพธ์เป็น JSON | ใช้แฟล็ก `--refresh` เพื่ออัปเดตรายการโมเดลที่แคชไว้ มีประโยชน์เมื่อมีการเพิ่มโมเดลใหม่ให้กับผู้ให้บริการและคุณต้องการเห็นใน OpenCode @@ -336,20 +340,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--command` | | คำสั่งที่จะรัน (ใช้ส่วนที่เหลือของ args เป็นอาร์กิวเมนต์) | -| `--continue` | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | -| `--fork` | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | -| `--share` | | สร้างลิงก์แชร์สำหรับเซสชัน | -| `--model` | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | -| `--agent` | | เอเจนต์ที่จะใช้ | -| `--file` | `-f` | แนบไฟล์ไปกับข้อความ | -| `--format` | | รูปแบบเอาต์พุต: text (จัดรูปแบบ) หรือ json (JSON ดิบ) | -| `--title` | | ชื่อสำหรับเซสชัน (หากไม่ได้ระบุ จะสร้างจากพรอมต์) | -| `--attach` | | แนบไปกับเซิร์ฟเวอร์ opencode ที่ทำงานอยู่ (เช่น http://localhost:4096) | -| `--port` | | พอร์ตสำหรับเซิร์ฟเวอร์ภายในเครื่อง (หากไม่ได้ระบุ จะใช้พอร์ตสุ่ม) | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| {"--command"} | | คำสั่งที่จะรัน (ใช้ส่วนที่เหลือของ args เป็นอาร์กิวเมนต์) | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--share"} | | สร้างลิงก์แชร์สำหรับเซสชัน | +| {"--model"} | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | +| {"--agent"} | | เอเจนต์ที่จะใช้ | +| {"--file"} | `-f` | แนบไฟล์ไปกับข้อความ | +| {"--format"} | | รูปแบบเอาต์พุต: text (จัดรูปแบบ) หรือ json (JSON ดิบ) | +| {"--title"} | | ชื่อสำหรับเซสชัน (หากไม่ได้ระบุ จะสร้างจากพรอมต์) | +| {"--attach"} | | แนบไปกับเซิร์ฟเวอร์ opencode ที่ทำงานอยู่ (เช่น http://localhost:4096) | +| {"--password"} | `-p` | รหัสผ่านการยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | ชื่อผู้ใช้การยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_USERNAME` หรือ `opencode`) | +| {"--dir"} | | ไดเร็กทอรีสำหรับรัน หรือเส้นทางบนเซิร์ฟเวอร์ระยะไกลเมื่อแนบ | +| {"--variant"} | | ตัวแปรโมเดล (ระดับการใช้เหตุผลเฉพาะผู้ให้บริการ) | +| {"--thinking"} | | แสดงบล็อกความคิด | +| {"--port"} | | พอร์ตสำหรับเซิร์ฟเวอร์ภายในเครื่อง (หากไม่ได้ระบุ จะใช้พอร์ตสุ่ม) | --- @@ -365,12 +374,12 @@ opencode serve #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | -------------------------------------- | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | -| `--mdns` | เปิดใช้งานการค้นหา mDNS | -| `--cors` | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | -------------------------------------- | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | +| {"--mdns"} | เปิดใช้งานการค้นหา mDNS | +| {"--cors"} | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | --- @@ -394,10 +403,10 @@ opencode session list ##### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------- | ---- | ----------------------------- | -| `--max-count` | `-n` | จำกัดการแสดงผล N รายการล่าสุด | -| `--format` | | รูปแบบ table หรือ json | +| แฟล็ก | สั้น | คำอธิบาย | +| ----------------------------------------- | ---- | ----------------------------- | +| {"--max-count"} | `-n` | จำกัดการแสดงผล N รายการล่าสุด | +| {"--format"} | | รูปแบบ table หรือ json | --- @@ -411,12 +420,12 @@ opencode stats #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ----------- | ---------------------------------------------------- | -| `--days` | แสดงสถิติของ N วันที่ผ่านมา (ค่าเริ่มต้น: ตลอดเวลา) | -| `--tools` | แสดงสถิติการใช้เครื่องมือ | -| `--models` | แสดงรายละเอียดการใช้งานโมเดล (ซ่อนไว้ตามค่าเริ่มต้น) | -| `--project` | กรองตามโครงการ (ค่าเริ่มต้น: โครงการปัจจุบัน) | +| แฟล็ก | คำอธิบาย | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | แสดงสถิติของ N วันที่ผ่านมา (ค่าเริ่มต้น: ตลอดเวลา) | +| {"--tools"} | แสดงสถิติการใช้เครื่องมือ | +| {"--models"} | แสดงรายละเอียดการใช้งานโมเดล (ซ่อนไว้ตามค่าเริ่มต้น) | +| {"--project"} | กรองตามโครงการ (ค่าเริ่มต้น: โครงการปัจจุบัน) | --- @@ -461,12 +470,12 @@ opencode web #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | -------------------------------------- | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | -| `--mdns` | เปิดใช้งานการค้นหา mDNS | -| `--cors` | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | -------------------------------------- | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | +| {"--mdns"} | เปิดใช้งานการค้นหา mDNS | +| {"--cors"} | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | --- @@ -482,11 +491,11 @@ opencode acp #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | ------------------ | -| `--cwd` | ไดเร็กทอรีการทำงาน | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | ------------------ | +| {"--cwd"} | ไดเร็กทอรีการทำงาน | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | --- @@ -500,12 +509,12 @@ opencode uninstall #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| --------------- | ---- | ----------------------------------- | -| `--keep-config` | `-c` | เก็บไฟล์การกำหนดค่าไว้ | -| `--keep-data` | `-d` | เก็บไฟล์ข้อมูล (เซสชันและสแน็ปช็อต) | -| `--dry-run` | | แสดงสิ่งที่จะลบออกโดยไม่ต้องทำจริง | -| `--force` | `-f` | บังคับลบโดยไม่มีการแจ้งเตือน | +| แฟล็ก | สั้น | คำอธิบาย | +| ------------------------------------------- | ---- | ----------------------------------- | +| {"--keep-config"} | `-c` | เก็บไฟล์การกำหนดค่าไว้ | +| {"--keep-data"} | `-d` | เก็บไฟล์ข้อมูล (เซสชันและสแน็ปช็อต) | +| {"--dry-run"} | | แสดงสิ่งที่จะลบออกโดยไม่ต้องทำจริง | +| {"--force"} | `-f` | บังคับลบโดยไม่มีการแจ้งเตือน | --- @@ -531,9 +540,9 @@ opencode upgrade v0.1.48 #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ---------- | ---- | ----------------------------------------------- | -| `--method` | `-m` | วิธีการติดตั้งที่ใช้ curl, npm, pnpm, bun, brew | +| แฟล็ก | สั้น | คำอธิบาย | +| -------------------------------------- | ---- | ----------------------------------------------- | +| {"--method"} | `-m` | วิธีการติดตั้งที่ใช้ curl, npm, pnpm, bun, brew | --- @@ -541,12 +550,12 @@ opencode upgrade v0.1.48 OpenCode CLI ยอมรับแฟล็กสากลต่อไปนี้สำหรับทุกคำสั่ง -| แฟล็ก | สั้น | คำอธิบาย | -| -------------- | ---- | ----------------------------------------- | -| `--help` | `-h` | แสดงความช่วยเหลือ | -| `--version` | `-v` | พิมพ์เวอร์ชัน | -| `--print-logs` | | พิมพ์บันทึกไปยัง stderr | -| `--log-level` | | ระดับการบันทึก (DEBUG, INFO, WARN, ERROR) | +| แฟล็ก | สั้น | คำอธิบาย | +| ------------------------------------------ | ---- | ----------------------------------------- | +| {"--help"} | `-h` | แสดงความช่วยเหลือ | +| {"--version"} | `-v` | พิมพ์เวอร์ชัน | +| {"--print-logs"} | | พิมพ์บันทึกไปยัง stderr | +| {"--log-level"} | | ระดับการบันทึก (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/th/modes.mdx b/packages/web/src/content/docs/th/modes.mdx deleted file mode 100644 index 6cca309987..0000000000 --- a/packages/web/src/content/docs/th/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: โหมด -description: โหมดที่แตกต่างกันสำหรับกรณีการใช้งานที่แตกต่างกัน ---- - -:::caution -ขณะนี้โหมดได้รับการกำหนดค่าผ่านตัวเลือก `agent` ในการกำหนดค่า opencode ที่ -ตัวเลือก `mode` เลิกใช้แล้ว [เรียนรู้เพิ่มเติม](/docs/agents) -::: - -โหมดใน opencode ช่วยให้คุณปรับแต่งพฤติกรรม เครื่องมือ และพร้อมท์สำหรับกรณีการใช้งานที่แตกต่างกันได้ - -มาพร้อมกับโหมดในตัวสองโหมด: **สร้าง** และ **วางแผน** คุณสามารถปรับแต่งได้ -สิ่งเหล่านี้หรือกำหนดค่าของคุณเองผ่านการกำหนดค่า opencode - -คุณสามารถสลับระหว่างโหมดระหว่างเซสชันหรือกำหนดค่าในไฟล์กำหนดค่าของคุณ - ---- - -## บิวท์อิน - -opencode มาพร้อมกับโหมดในตัวสองโหมด - ---- - -### Build - -Build เป็นโหมด **ค่าเริ่มต้น** โดยที่เครื่องมือทั้งหมดเปิดใช้งานอยู่ นี่คือโหมดมาตรฐานสำหรับงานพัฒนาที่คุณต้องการสิทธิ์เข้าถึงการทำงานของไฟล์และคำสั่งระบบโดยสมบูรณ์ - ---- - -### Plan - -โหมดจำกัดที่ออกแบบมาเพื่อการวางแผนและการวิเคราะห์ ในโหมดแผน เครื่องมือต่อไปนี้จะถูกปิดใช้งานตามค่าเริ่มต้น: - -- `write` - ​​ไม่สามารถสร้างไฟล์ใหม่ได้ -- `edit` - ​​ไม่สามารถแก้ไขไฟล์ที่มีอยู่ได้ ยกเว้นไฟล์ที่อยู่ใน `.opencode/plans/*.md` เพื่อดูรายละเอียดแผนงาน -- `patch` - ​​ไม่สามารถใช้แพตช์ได้ -- `bash` - ​​ไม่สามารถรันคำสั่ง shell ได้ - -โหมดนี้มีประโยชน์เมื่อคุณต้องการให้ AI วิเคราะห์โค้ด แนะนำการเปลี่ยนแปลง หรือสร้างแผนโดยไม่ต้องทำการแก้ไขโค้ดเบสของคุณจริง ๆ - ---- - -## การสลับ - -คุณสามารถสลับระหว่างโหมดระหว่างเซสชันได้โดยใช้ปุ่ม _Tab_ หรือการเชื่อมโยงคีย์ `switch_mode` ที่คุณกำหนดค่าไว้ - -ดูเพิ่มเติมที่: [Formatters](/docs/formatters) สำหรับข้อมูลเกี่ยวกับการกำหนดค่าการจัดรูปแบบโค้ด - ---- - -## กำหนดค่า - -คุณสามารถปรับแต่งโหมดในตัวหรือสร้างโหมดของคุณเองผ่านการกำหนดค่าได้ โหมดสามารถกำหนดค่าได้สองวิธี: - -### การกำหนดค่าด้วย JSON - -กำหนดค่าโหมดในไฟล์กำหนดค่า `opencode.json` ของคุณ: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### การกำหนดค่า Markdown - -คุณยังสามารถกำหนดโหมดโดยใช้ไฟล์ Markdown ได้ วางไว้ใน: - -- ทั่วโลก: `~/.config/opencode/modes/` -- โครงการ: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -ชื่อไฟล์ Markdown จะกลายเป็นชื่อโหมด (เช่น `review.md` สร้างโหมด `review`) - -มาดูรายละเอียดตัวเลือกการกำหนดค่าเหล่านี้กัน - ---- - -### Model (โมเดล) - -ใช้การกำหนดค่า `model` เพื่อแทนที่โมเดลเริ่มต้นสำหรับโหมดนี้ มีประโยชน์สำหรับการใช้โมเดลที่แตกต่างกันซึ่งปรับให้เหมาะกับงานที่แตกต่างกัน ตัวอย่างเช่น โมเดลสำหรับการวางแผนที่เร็วขึ้น โมเดลที่มีความสามารถมากขึ้นสำหรับการนำไปปฏิบัติ - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Temperature (อุณหภูมิ) - -ควบคุมการสุ่มและความคิดสร้างสรรค์ของการตอบสนองของ AI ด้วยการกำหนดค่า `temperature` ค่าที่ต่ำกว่าจะทำให้คำตอบมีจุดมุ่งหมายและกำหนดได้มากขึ้น ในขณะที่ค่าที่สูงกว่าจะเพิ่มความคิดสร้างสรรค์และความแปรปรวน - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -โดยทั่วไปค่าอุณหภูมิจะอยู่ในช่วงตั้งแต่ 0.0 ถึง 1.0: - -- **0.0-0.2**: การตอบสนองที่มุ่งเน้นและกำหนดไว้อย่างมาก เหมาะสำหรับการวิเคราะห์และวางแผนโค้ด -- **0.3-0.5**: การตอบสนองที่สมดุลและความคิดสร้างสรรค์บางส่วน เหมาะสำหรับงานพัฒนาทั่วไป -- **0.6-1.0**: คำตอบที่สร้างสรรค์และหลากหลายยิ่งขึ้น ซึ่งมีประโยชน์สำหรับการระดมความคิดและการสำรวจ - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -หากไม่มีการระบุอุณหภูมิ opencode จะใช้ค่าเริ่มต้นเฉพาะรุ่น (โดยทั่วไปจะเป็น 0 สำหรับรุ่นส่วนใหญ่ และ 0.55 สำหรับรุ่น Qwen) - ---- - -### พรอมต์ - -ระบุไฟล์พรอมต์ระบบที่กำหนดเองสำหรับโหมดนี้ด้วยการกำหนดค่า `prompt` ไฟล์พร้อมท์ควรมีคำแนะนำเฉพาะสำหรับวัตถุประสงค์ของโหมด - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -เส้นทางนี้สัมพันธ์กับตำแหน่งของไฟล์กำหนดค่า ดังนั้นสิ่งนี้จึงใช้ได้กับ -ทั้งการกำหนดค่า opencode ส่วนกลางและการกำหนดค่าเฉพาะโครงการ - ---- - -### เครื่องมือ - -ควบคุมว่าเครื่องมือใดบ้างที่พร้อมใช้งานในโหมดนี้ด้วยการกำหนดค่า `tools` คุณสามารถเปิดหรือปิดใช้งานเครื่องมือเฉพาะได้โดยตั้งค่าเป็น `true` หรือ `false` - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -หากไม่มีการระบุเครื่องมือ เครื่องมือทั้งหมดจะถูกเปิดใช้งานตามค่าเริ่มต้น - ---- - -#### เครื่องมือที่มีอยู่ - -นี่คือเครื่องมือทั้งหมดที่สามารถควบคุมได้ผ่านการกำหนดค่าโหมด - -| เครื่องมือ | คำอธิบาย | -| ----------- | ------------------------- | -| `bash` | ดำเนินการคำสั่ง shell | -| `edit` | แก้ไขไฟล์ที่มีอยู่ | -| `write` | สร้างไฟล์ใหม่ | -| `read` | อ่านเนื้อหาไฟล์ | -| `grep` | ค้นหาเนื้อหาไฟล์ | -| `glob` | ค้นหาไฟล์ตามรูปแบบ | -| `patch` | ใช้แพทช์กับไฟล์ | -| `todowrite` | จัดการรายการสิ่งที่ต้องทำ | -| `webfetch` | ดึงเนื้อหาเว็บ | - ---- - -## โหมดกำหนดเอง - -คุณสามารถสร้างโหมดที่คุณกำหนดเองได้โดยเพิ่มเข้าไปในการกำหนดค่า นี่คือตัวอย่างที่ใช้ทั้งสองวิธี: - -### ใช้การกำหนดค่า JSON - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### การใช้ไฟล์ Markdown - -สร้างไฟล์โหมดใน `.opencode/modes/` สำหรับโหมดเฉพาะโครงการหรือ `~/.config/opencode/modes/` สำหรับโหมดทั่วโลก: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### กรณีการใช้งาน - -ต่อไปนี้เป็นกรณีการใช้งานทั่วไปสำหรับโหมดต่างๆ - -- **Build mode**: งานพัฒนาเต็มรูปแบบโดยเปิดใช้งานเครื่องมือทั้งหมด -- **Plan mode**: วิเคราะห์และวางแผนโดยไม่ทำการเปลี่ยนแปลง -- **Review mode**: การตรวจสอบโค้ดพร้อมการเข้าถึงแบบอ่านอย่างเดียวพร้อมเครื่องมือเอกสารประกอบ -- **Debug mode**: มุ่งเน้นไปที่การตรวจสอบโดยเปิดใช้งานเครื่องมือ bash และอ่าน -- **Docs mode**: การเขียนเอกสารด้วยการทำงานของไฟล์ แต่ไม่มีคำสั่งระบบ - -คุณอาจพบว่ารุ่นต่างๆ นั้นดีสำหรับกรณีการใช้งานที่แตกต่างกัน diff --git a/packages/web/src/content/docs/th/providers.mdx b/packages/web/src/content/docs/th/providers.mdx index 818f39213c..07008de218 100644 --- a/packages/web/src/content/docs/th/providers.mdx +++ b/packages/web/src/content/docs/th/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม --- -### Firmware +### FrogBot -1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API +1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API -2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware** +2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot** ```txt /connect ``` -3. ป้อนคีย์ Firmware API ของคุณ +3. ป้อนคีย์ FrogBot API ของคุณ ```txt ┌ API key diff --git a/packages/web/src/content/docs/tr/cli.mdx b/packages/web/src/content/docs/tr/cli.mdx index 75ecca9926..25b74ecfe4 100644 --- a/packages/web/src/content/docs/tr/cli.mdx +++ b/packages/web/src/content/docs/tr/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ------------ | ---- | --------------------------------------------------------------------------- | -| `--continue` | `-c` | Son oturuma devam et | -| `--session` | `-s` | Devam edecek oturum kimliği | -| `--fork` | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | -| `--prompt` | | Kullanılacak prompt | -| `--model` | `-m` | provider/model biçiminde kullanılacak model | -| `--agent` | | Kullanılacak agent | -| `--port` | | Dinlenecek port | -| `--hostname` | | Dinlenecek host adı | +| Bayrak | Kısa | Açıklama | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum kimliği | +| {"--fork"} | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | +| {"--prompt"} | | Kullanılacak prompt | +| {"--model"} | `-m` | provider/model biçiminde kullanılacak model | +| {"--agent"} | | Kullanılacak agent | +| {"--port"} | | Dinlenecek port | +| {"--hostname"} | | Dinlenecek host adı | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Bayraklar -| Bayrak | Kısa | Tanım | -| ----------- | ---- | ------------------------------------ | -| `--dir` | | TUI'yi başlatmak için çalışma dizini | -| `--session` | `-s` | Devam edecek oturum açma bilgileri | +| Bayrak | Kısa | Tanım | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | TUI'yi başlatmak için çalışma dizini | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum açma bilgileri | +| {"--fork"} | | Devam ederken oturumu çatalla (`--continue` veya `--session` ile kullanın) | +| {"--password"} | `-p` | Temel kimlik doğrulama parolası (varsayılan: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Temel kimlik doğrulama kullanıcı adı (varsayılan: `OPENCODE_SERVER_USERNAME` veya `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Bayraklar -| Bayrak | Açıklama | -| --------- | ------------------------------------------- | -| `--event` | Aracıyı çalıştırmak için GitHub sahte olayı | -| `--token` | GitHub personal access token | +| Bayrak | Açıklama | +| ------------------------------------- | ------------------------------------------- | +| {"--event"} | Aracıyı çalıştırmak için GitHub sahte olayı | +| {"--token"} | GitHub personal access token | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Bayraklar -| Bayrak | Tanım | -| ----------- | --------------------------------------------------------------------------- | -| `--refresh` | Modeller.dev'den model önbelleğini yenileyin | -| `--verbose` | Daha ayrıntılı model çıktısı kullanın (maliyetler gibi meta veriler içerir) | +| Bayrak | Tanım | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--refresh"} | Modeller.dev'den model önbelleğini yenileyin | +| {"--verbose"} | Daha ayrıntılı model çıktısı kullanın (maliyetler gibi meta veriler içerir) | Önbelleğe alınan model listesini güncellemek için `--refresh` bayrağını kullanın. Bu, bir sağlayıcıya yeni modeller eklendiğinde ve bunları opencode'da görmek istediğinizde kullanışlıdır. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ------------ | ---- | --------------------------------------------------------------------------------- | -| `--command` | | Çalıştırılacak komut, args için mesajı kullanın | -| `--continue` | `-c` | Son oturuma devam et | -| `--session` | `-s` | Devam edecek oturum kimliği | -| `--fork` | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | -| `--share` | | Oturumu paylaşın | -| `--model` | `-m` | provider/model biçiminde kullanılacak model | -| `--agent` | | Kullanılacak temsilci | -| `--file` | `-f` | Mesaja eklenecek dosya(lar) | -| `--format` | | Biçim: varsayılan (biçimlendirilmiş) veya json (ham JSON olayları) | -| `--title` | | Oturumun başlığı (değer sağlanmazsa kısaltılmış bilgi istemi kullanılır) | -| `--attach` | | Çalışan bir opencode sunucusuna ekleyin (ör. http://localhost:4096) | -| `--port` | | Yerel sunucunun bağlantı noktası (varsayılan olarak rastgele bağlantı noktasıdır) | +| Bayrak | Kısa | Açıklama | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Çalıştırılacak komut, args için mesajı kullanın | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum kimliği | +| {"--fork"} | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | +| {"--share"} | | Oturumu paylaşın | +| {"--model"} | `-m` | provider/model biçiminde kullanılacak model | +| {"--agent"} | | Kullanılacak temsilci | +| {"--file"} | `-f` | Mesaja eklenecek dosya(lar) | +| {"--format"} | | Biçim: varsayılan (biçimlendirilmiş) veya json (ham JSON olayları) | +| {"--title"} | | Oturumun başlığı (değer sağlanmazsa kısaltılmış bilgi istemi kullanılır) | +| {"--attach"} | | Çalışan bir opencode sunucusuna ekleyin (ör. http://localhost:4096) | +| {"--password"} | `-p` | Temel kimlik doğrulama parolası (varsayılan: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Temel kimlik doğrulama kullanıcı adı (varsayılan: `OPENCODE_SERVER_USERNAME` veya `opencode`) | +| {"--dir"} | | Çalıştırılacak dizin veya bağlanırken uzak sunucudaki yol | +| {"--variant"} | | Model varyantı (sağlayıcıya özgü muhakeme düzeyi) | +| {"--thinking"} | | Düşünme bloklarını göster | +| {"--port"} | | Yerel sunucunun bağlantı noktası (varsayılan olarak rastgele bağlantı noktasıdır) | --- @@ -364,12 +373,12 @@ Bu, TUI arayüzü olmadan opencode işlevselliğine API erişimi sağlayan bir H #### Bayraklar -| Bayrak | Tanım | -| ------------ | ------------------------------------------ | -| `--port` | Dinlenecek bağlantı noktası | -| `--hostname` | Dinlenecek ana bilgisayar adı | -| `--mdns` | mDNS bulmayı etkinleştir | -| `--cors` | CORS'a izin verecek ek tarayıcı kaynakları | +| Bayrak | Tanım | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Dinlenecek bağlantı noktası | +| {"--hostname"} | Dinlenecek ana bilgisayar adı | +| {"--mdns"} | mDNS bulmayı etkinleştir | +| {"--cors"} | CORS'a izin verecek ek tarayıcı kaynakları | --- @@ -393,10 +402,10 @@ opencode session list ##### Bayraklar -| Bayrak | Kısa | Tanım | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | En son N oturumla sınırla | -| `--format` | | Çıkış formatı: tablo veya json (tablo) | +| Bayrak | Kısa | Tanım | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | En son N oturumla sınırla | +| {"--format"} | | Çıkış formatı: tablo veya json (tablo) | --- @@ -410,12 +419,12 @@ opencode stats #### Bayraklar -| Bayrak | Açıklama | -| ----------- | ----------------------------------------------------------------------------------------------------------- | -| `--days` | Son N güne ait istatistikleri göster (tüm zamanlar) | -| `--tools` | Gösterilecek araç sayısı (tümü) | -| `--models` | Model kullanım dökümünü göster (varsayılan olarak gizlidir). En üstteki N'yi göstermek için bir sayı iletin | -| `--project` | Projeye göre filtrele (tüm projeler, boş değer: mevcut proje) | +| Bayrak | Açıklama | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| {"--days"} | Son N güne ait istatistikleri göster (tüm zamanlar) | +| {"--tools"} | Gösterilecek araç sayısı (tümü) | +| {"--models"} | Model kullanım dökümünü göster (varsayılan olarak gizlidir). En üstteki N'yi göstermek için bir sayı iletin | +| {"--project"} | Projeye göre filtrele (tüm projeler, boş değer: mevcut proje) | --- @@ -460,12 +469,12 @@ Bu, bir HTTP sunucusunu başlatır ve bir web arayüzü aracılığıyla opencod #### Bayraklar -| Bayrak | Tanım | -| ------------ | ------------------------------------------ | -| `--port` | Dinlenecek bağlantı noktası | -| `--hostname` | Dinlenecek ana bilgisayar adı | -| `--mdns` | mDNS bulmayı etkinleştir | -| `--cors` | CORS'a izin verecek ek tarayıcı kaynakları | +| Bayrak | Tanım | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Dinlenecek bağlantı noktası | +| {"--hostname"} | Dinlenecek ana bilgisayar adı | +| {"--mdns"} | mDNS bulmayı etkinleştir | +| {"--cors"} | CORS'a izin verecek ek tarayıcı kaynakları | --- @@ -481,11 +490,11 @@ Bu komut, nd-JSON kullanarak stdin/stdout aracılığıyla iletişim kuran bir A #### Bayraklar -| Bayrak | Açıklama | -| ------------ | ------------------- | -| `--cwd` | Çalışma dizini | -| `--port` | Dinlenecek port | -| `--hostname` | Dinlenecek host adı | +| Bayrak | Açıklama | +| ---------------------------------------- | ------------------- | +| {"--cwd"} | Çalışma dizini | +| {"--port"} | Dinlenecek port | +| {"--hostname"} | Dinlenecek host adı | --- @@ -499,12 +508,12 @@ opencode uninstall #### Bayraklar -| Bayrak | Kısa | Tanım | -| --------------- | ---- | ----------------------------------------------- | -| `--keep-config` | `-c` | Yapılandırma dosyalarını sakla | -| `--keep-data` | `-d` | Oturum verilerini ve anlık görüntüleri saklayın | -| `--dry-run` | | Nelerin kaldırılmadan kaldırılacağı göster | -| `--force` | `-f` | Onay istemlerini atla | +| Bayrak | Kısa | Tanım | +| ------------------------------------------- | ---- | ----------------------------------------------- | +| {"--keep-config"} | `-c` | Yapılandırma dosyalarını sakla | +| {"--keep-data"} | `-d` | Oturum verilerini ve anlık görüntüleri saklayın | +| {"--dry-run"} | | Nelerin kaldırılmadan kaldırılacağı göster | +| {"--force"} | `-f` | Onay istemlerini atla | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ---------- | ---- | ------------------------------------------------------ | -| `--method` | `-m` | Kullanılan kurulum yöntemi: curl, npm, pnpm, bun, brew | +| Bayrak | Kısa | Açıklama | +| -------------------------------------- | ---- | ------------------------------------------------------ | +| {"--method"} | `-m` | Kullanılan kurulum yöntemi: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI aşağıdaki global bayrakları destekler. -| Bayrak | Kısa | Tanım | -| -------------- | ---- | ---------------------------------------- | -| `--help` | `-h` | Yardımı görüntüle | -| `--version` | `-v` | Sürüm numarasını yazdır | -| `--print-logs` | | Günlükleri stderr'e yazdır | -| `--log-level` | | Günlük düzeyi (DEBUG, INFO, WARN, ERROR) | +| Bayrak | Kısa | Tanım | +| ------------------------------------------ | ---- | ---------------------------------------- | +| {"--help"} | `-h` | Yardımı görüntüle | +| {"--version"} | `-v` | Sürüm numarasını yazdır | +| {"--print-logs"} | | Günlükleri stderr'e yazdır | +| {"--log-level"} | | Günlük düzeyi (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/tr/modes.mdx b/packages/web/src/content/docs/tr/modes.mdx deleted file mode 100644 index 8f722ec228..0000000000 --- a/packages/web/src/content/docs/tr/modes.mdx +++ /dev/null @@ -1,329 +0,0 @@ ---- -title: Modlar -description: farklı kullanım durumları için farklı modlar. ---- - -:::caution -Modlar artık opencode yapılandırmasındaki `agent` seçeneği aracılığıyla yapılandırılıyor. -`mode` seçeneği artık kullanımdan kaldırıldı. [Daha fazla bilgi](/docs/agents). -::: - -opencode'daki modlar, farklı kullanım durumları için davranışı, araçları ve istemleri özelleştirmenize olanak tanır. - -İki yerleşik modla birlikte gelir: **build** ve **plan**. Kişiselleştirebilirsiniz -bunları veya opencode yapılandırması aracılığıyla kendinizinkini yapılandırın. - -Bir oturum sırasında modlar arasında geçiş yapabilir veya bunları yapılandırma dosyanızda yapılandırabilirsiniz. - ---- - -## Yerleşik - -opencode iki yerleşik modla birlikte gelir. - ---- - -### Build - -Derleme, tüm araçların etkin olduğu **varsayılan** moddur. Bu, dosya işlemlerine ve sistem komutlarına tam erişime ihtiyaç duyduğunuz geliştirme çalışmaları için standart moddur. - ---- - -### Plan - -Planlama ve analiz için tasarlanmış sınırlı bir mod. Plan modunda aşağıdaki araçlar varsayılan olarak devre dışıdır: - -- `write` - Yeni dosyalar oluşturulamıyor -- `edit` - Planın kendisini detaylandırmak için `.opencode/plans/*.md` adresinde bulunan dosyalar dışında mevcut dosyalar değiştirilemez -- `patch` - Yamalar uygulanamaz -- `bash` - Kabuk komutları yürütülemiyor - -Bu mod, yapay zekanın kod tabanınızda herhangi bir gerçek değişiklik yapmadan kodu analiz etmesini, değişiklik önermesini veya plan oluşturmasını istediğinizde kullanışlıdır. - ---- - -## Geçiş - -Bir oturum sırasında _Tab_ tuşunu kullanarak modlar arasında geçiş yapabilirsiniz. Veya yapılandırılmış `switch_mode` tuş bağlantınız. - -Ayrıca bkz.: Kod biçimlendirme yapılandırması hakkında bilgi için [Formatters](/docs/formatters). - ---- - -## Yapılandırma - -Yerleşik modları özelleştirebilir veya yapılandırma aracılığıyla kendinizinkini oluşturabilirsiniz. Modlar iki şekilde yapılandırılabilir: - -### JSON Yapılandırması - -`opencode.json` yapılandırma dosyanızdaki modları yapılandırın: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown Yapılandırması - -Markdown dosyalarını kullanarak modları da tanımlayabilirsiniz. Bunları şuraya yerleştirin: - -- Global: `~/.config/opencode/modes/` -- Project: `.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown dosyası adı mod adı olur (örneğin, `review.md` bir `review` modu oluşturur). - -Bu yapılandırma seçeneklerine ayrıntılı olarak bakalım. - ---- - -### Model - -Bu modun varsayılan modelini geçersiz kılmak için `model` ayarını kullanın. Farklı bölümler için optimize edilmiş farklı modelleri kullanmak için kullanışlıdır. Örneğin planlama için daha hızlı bir model, uygulama için daha yetenekli bir model. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### Sıcaklık - -`temperature` yapılandırmasıyla yapay zekanın yanıtlarının rastgeleliğini ve yaratıcılığını kontrol edin. Düşük değerler yanıtları daha odaklı ve belirleyici hale getirirken, yüksek değerler yaratıcılığı ve değişkenliği artırır. - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -Sıcaklık değerleri tipik olarak 0,0 ila 1,0 arasındadır: - -- **0,0-0,2**: Çok odaklı ve belirleyici yanıtlar, kod analizi ve planlaması için idealdir -- **0,3-0,5**: Biraz yaratıcılık içeren dengeli yanıtlar, genel gelişim görevleri için iyi -- **0,6-1,0**: Daha yaratıcı ve çeşitli yanıtlar, beyin fırtınası ve keşif için yararlı - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -Sıcaklık belirtilmezse, opencode modele özgü varsayılanları kullanır (çoğu model için genellikle 0, Qwen modelleri için 0,55). - ---- - -### İstem - -`prompt` yapılandırmasıyla bu mod için özel bir sistem bilgi istemi dosyası belirtin. Bilgi istemi dosyası, modun amacına özel talimatlar içermelidir. - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -Bu yol, yapılandırma dosyasının bulunduğu yere göredir. Yani bu işe yarıyor -hem global opencode yapılandırması hem de projeye özel yapılandırma. - ---- - -### Araçlar - -`tools` yapılandırmasıyla bu modda hangi araçların kullanılabileceğini kontrol edin. Belirli araçları `true` veya `false` olarak ayarlayarak etkinleştirebilir veya devre dışı bırakabilirsiniz. - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -Hiçbir araç belirtilmezse tüm araçlar varsayılan olarak etkindir. - ---- - -#### Mevcut araçlar - -İşte mod yapılandırması aracılığıyla kontrol edilebilecek tüm araçlar. - -| Araç | Açıklama | -| ----------- | ------------------------------ | -| `bash` | Kabuk komutlarını yürütün | -| `edit` | Mevcut dosyaları değiştirin | -| `write` | Yeni dosyalar oluştur | -| `read` | Dosya içeriğini oku | -| `grep` | Dosya içeriğini ara | -| `glob` | Dosyaları desene göre bul | -| `patch` | Dosyalara yama uygula | -| `todowrite` | Yapılacaklar listelerini yönet | -| `webfetch` | Web içeriğini getir | - ---- - -## Özel modlar - -Yapılandırmaya ekleyerek kendi özel modlarınızı oluşturabilirsiniz. Her iki yaklaşımın kullanıldığı örnekler aşağıda verilmiştir: - -### JSON yapılandırması kullanma - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### Markdown dosyaları kullanma - -Projeye özel modlar için `.opencode/modes/` veya genel modlar için `~/.config/opencode/modes/`'de mod dosyaları oluşturun: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### Kullanım Senaryoları - -Farklı modlar için bazı yaygın kullanım senaryoları aşağıda verilmiştir. - -- **Derleme modu**: Tüm araçların etkinleştirildiği tam geliştirme çalışması -- **Plan modu**: Değişiklik yapmadan analiz ve planlama -- **İnceleme modu**: Salt okunur erişim ve belgeleme araçlarıyla kod incelemesi -- **Hata ayıklama modu**: Bash ve okuma araçları etkinken araştırmaya odaklanıldı -- **Belgeler modu**: Dosya işlemleriyle ancak sistem komutları olmadan belge yazma - -Ayrıca farklı modellerin farklı kullanım durumları için iyi olduğunu da görebilirsiniz. diff --git a/packages/web/src/content/docs/tr/providers.mdx b/packages/web/src/content/docs/tr/providers.mdx index 527c20e15e..8c6ef23fee 100644 --- a/packages/web/src/content/docs/tr/providers.mdx +++ b/packages/web/src/content/docs/tr/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model --- -### Firmware +### FrogBot -1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. +1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. -2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın. +2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın. ```txt /connect ``` -3. Firmware API anahtarınızı girin. +3. FrogBot API anahtarınızı girin. ```txt ┌ API key diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index e89fb4af39..73ecce93b5 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -99,8 +99,6 @@ Toggle tool execution details. /details ``` -**Keybind:** `ctrl+x d` - --- ### editor @@ -147,8 +145,6 @@ Show the help dialog. /help ``` -**Keybind:** `ctrl+x h` - --- ### init @@ -159,8 +155,6 @@ Guided setup for creating or updating `AGENTS.md`. [Learn more](/docs/rules). /init ``` -**Keybind:** `ctrl+x i` - --- ### models @@ -226,8 +220,6 @@ Share current session. [Learn more](/docs/share). /share ``` -**Keybind:** `ctrl+x s` - --- ### themes @@ -366,7 +358,7 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). }, "scroll_speed": 3, "scroll_acceleration": { - "enabled": true + "enabled": false }, "diff_style": "auto", "mouse": true @@ -390,7 +382,7 @@ Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. ## Customization -You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts. +You can customize various aspects of the TUI view using the command palette (`ctrl+p`). These settings persist across restarts. --- diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx index c0cff134a5..aa992b6734 100644 --- a/packages/web/src/content/docs/zh-cn/cli.mdx +++ b/packages/web/src/content/docs/zh-cn/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 标志 -| 标志 | 简写 | 描述 | -| ------------ | ---- | --------------------------------------------------------- | -| `--continue` | `-c` | 继续上一个会话 | -| `--session` | `-s` | 要继续的会话 ID | -| `--fork` | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | -| `--prompt` | | 要使用的提示词 | -| `--model` | `-m` | 要使用的模型,格式为 provider/model | -| `--agent` | | 要使用的代理 | -| `--port` | | 监听端口 | -| `--hostname` | | 监听主机名 | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | --------------------------------------------------------- | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--prompt"} | | 要使用的提示词 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 监听端口 | +| {"--hostname"} | | 监听主机名 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 标志 -| 标志 | 简写 | 描述 | -| ----------- | ---- | ------------------- | -| `--dir` | | 启动 TUI 的工作目录 | -| `--session` | `-s` | 要继续的会话 ID | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--dir"} | | 启动 TUI 的工作目录 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时派生会话(与 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 标志 -| 标志 | 描述 | -| --------- | ------------------------------ | -| `--event` | 用于运行代理的 GitHub 模拟事件 | -| `--token` | GitHub 个人访问令牌 | +| 标志 | 描述 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用于运行代理的 GitHub 模拟事件 | +| {"--token"} | GitHub 个人访问令牌 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 标志 -| 标志 | 描述 | -| ----------- | ---------------------------------------- | -| `--refresh` | 从 models.dev 刷新模型缓存 | -| `--verbose` | 使用更详细的模型输出(包含费用等元数据) | +| 标志 | 描述 | +| --------------------------------------- | ---------------------------------------- | +| {"--refresh"} | 从 models.dev 刷新模型缓存 | +| {"--verbose"} | 使用更详细的模型输出(包含费用等元数据) | 使用 `--refresh` 标志可以更新缓存的模型列表。当提供商新增了模型并且您希望在 OpenCode 中看到它们时,此功能非常有用。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 标志 -| 标志 | 简写 | 描述 | -| ------------ | ---- | -------------------------------------------------------------- | -| `--command` | | 要运行的命令,使用 message 作为参数 | -| `--continue` | `-c` | 继续上一个会话 | -| `--session` | `-s` | 要继续的会话 ID | -| `--fork` | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | -| `--share` | | 分享会话 | -| `--model` | `-m` | 要使用的模型,格式为 provider/model | -| `--agent` | | 要使用的代理 | -| `--file` | `-f` | 附加到消息的文件 | -| `--format` | | 格式:default(格式化输出)或 json(原始 JSON 事件) | -| `--title` | | 会话标题(未提供值时使用截断的提示词) | -| `--attach` | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | -| `--port` | | 本地服务器端口(默认为随机端口) | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--command"} | | 要运行的命令,使用 message 作为参数 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--share"} | | 分享会话 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到消息的文件 | +| {"--format"} | | 格式:default(格式化输出)或 json(原始 JSON 事件) | +| {"--title"} | | 会话标题(未提供值时使用截断的提示词) | +| {"--attach"} | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 运行目录,或附加时远程服务器上的路径 | +| {"--variant"} | | 模型变体(特定于提供商的推理级别) | +| {"--thinking"} | | 显示思考块 | +| {"--port"} | | 本地服务器端口(默认为随机端口) | --- @@ -364,12 +373,12 @@ opencode serve #### 标志 -| 标志 | 描述 | -| ------------ | -------------------------- | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | -| `--mdns` | 启用 mDNS 发现 | -| `--cors` | 允许 CORS 的额外浏览器来源 | +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | --- @@ -393,10 +402,10 @@ opencode session list ##### 标志 -| 标志 | 简写 | 描述 | -| ------------- | ---- | ------------------------------------- | -| `--max-count` | `-n` | 限制为最近 N 个会话 | -| `--format` | | 输出格式:table 或 json(默认 table) | +| 标志 | 简写 | 描述 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制为最近 N 个会话 | +| {"--format"} | | 输出格式:table 或 json(默认 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 标志 -| 标志 | 描述 | -| ----------- | ------------------------------------------------------ | -| `--days` | 显示最近 N 天的统计信息(默认为所有时间) | -| `--tools` | 显示的工具数量(默认为全部) | -| `--models` | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | -| `--project` | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | +| 标志 | 描述 | +| --------------------------------------- | ------------------------------------------------------ | +| {"--days"} | 显示最近 N 天的统计信息(默认为所有时间) | +| {"--tools"} | 显示的工具数量(默认为全部) | +| {"--models"} | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | +| {"--project"} | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | --- @@ -460,12 +469,12 @@ opencode web #### 标志 -| 标志 | 描述 | -| ------------ | -------------------------- | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | -| `--mdns` | 启用 mDNS 发现 | -| `--cors` | 允许 CORS 的额外浏览器来源 | +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | --- @@ -481,11 +490,11 @@ opencode acp #### 标志 -| 标志 | 描述 | -| ------------ | ---------- | -| `--cwd` | 工作目录 | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | +| 标志 | 描述 | +| ---------------------------------------- | ---------- | +| {"--cwd"} | 工作目录 | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 标志 -| 标志 | 简写 | 描述 | -| --------------- | ---- | ------------------------------ | -| `--keep-config` | `-c` | 保留配置文件 | -| `--keep-data` | `-d` | 保留会话数据和快照 | -| `--dry-run` | | 显示将被删除的内容但不实际删除 | -| `--force` | `-f` | 跳过确认提示 | +| 标志 | 简写 | 描述 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留配置文件 | +| {"--keep-data"} | `-d` | 保留会话数据和快照 | +| {"--dry-run"} | | 显示将被删除的内容但不实际删除 | +| {"--force"} | `-f` | 跳过确认提示 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 标志 -| 标志 | 简写 | 描述 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | +| 标志 | 简写 | 描述 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI 接受以下全局标志。 -| 标志 | 简写 | 描述 | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | 显示帮助信息 | -| `--version` | `-v` | 打印版本号 | -| `--print-logs` | | 将日志输出到 stderr | -| `--log-level` | | 日志级别(DEBUG、INFO、WARN、ERROR) | +| 标志 | 简写 | 描述 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 显示帮助信息 | +| {"--version"} | `-v` | 打印版本号 | +| {"--print-logs"} | | 将日志输出到 stderr | +| {"--log-level"} | | 日志级别(DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/zh-cn/modes.mdx b/packages/web/src/content/docs/zh-cn/modes.mdx deleted file mode 100644 index 256cffe811..0000000000 --- a/packages/web/src/content/docs/zh-cn/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: 模式 -description: 不同模式适用于不同的使用场景。 ---- - -:::caution -模式现在通过 opencode 配置中的 `agent` 选项进行配置。`mode` 选项已废弃。[了解更多](/docs/agents)。 -::: - -opencode 中的模式允许你为不同的使用场景自定义行为、工具和提示词。 - -opencode 自带两种内置模式:**build** 和 **plan**。你可以自定义这些模式,也可以通过 opencode 配置创建自己的模式。 - -你可以在会话中切换模式,也可以在配置文件中进行配置。 - ---- - -## 内置模式 - -opencode 自带两种内置模式。 - ---- - -### Build - -Build 是启用了所有工具的**默认**模式。这是进行开发工作的标准模式,你可以完全访问文件操作和系统命令。 - ---- - -### Plan - -Plan 是一种为规划和分析设计的受限模式。在 plan 模式下,以下工具默认被禁用: - -- `write` - 无法创建新文件 -- `edit` - 无法修改现有文件,但位于 `.opencode/plans/*.md` 的文件除外,用于详细说明计划本身 -- `patch` - 无法应用补丁 -- `bash` - 无法执行 shell 命令 - -当你希望 AI 分析代码、提出修改建议或制定计划,而不对代码库进行任何实际更改时,此模式非常有用。 - ---- - -## 切换 - -你可以在会话中使用 _Tab_ 键切换模式,或者使用你配置的 `switch_mode` 快捷键。 - -另请参阅:[格式化工具](/docs/formatters)了解代码格式化配置的相关信息。 - ---- - -## 配置 - -你可以自定义内置模式或通过配置创建自己的模式。模式可以通过两种方式进行配置: - -### JSON 配置 - -在 `opencode.json` 配置文件中配置模式: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 配置 - -你还可以使用 Markdown 文件定义模式。将文件放置在以下位置: - -- 全局:`~/.config/opencode/modes/` -- 项目:`.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 文件名即为模式名称(例如,`review.md` 创建一个名为 `review` 的模式)。 - -下面让我们详细了解这些配置选项。 - ---- - -### 模型 - -使用 `model` 配置可以覆盖该模式的默认模型。这对于针对不同任务使用不同模型非常有用。例如,规划时使用更快的模型,实现时使用更强大的模型。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 温度 - -使用 `temperature` 配置控制 AI 响应的随机性和创造性。较低的值使响应更加集中和确定性,较高的值则增加创造性和多样性。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -温度值的范围通常为 0.0 到 1.0: - -- **0.0-0.2**:非常集中且确定性高的响应,适合代码分析和规划 -- **0.3-0.5**:兼顾稳定性与创造力的平衡型响应,适合一般开发任务 -- **0.6-1.0**:更具创造性和多样性的响应,适合头脑风暴和探索性工作 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -如果未指定温度,opencode 将使用模型特定的默认值(大多数模型通常为 0,Qwen 模型为 0.55)。 - ---- - -### 提示词 - -使用 `prompt` 配置为模式指定自定义系统提示词文件。提示词文件应包含针对该模式用途的具体指令。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -此路径相对于配置文件所在位置。因此,全局 opencode 配置和项目特定配置均可使用。 - ---- - -### 工具 - -使用 `tools` 配置控制该模式下可用的工具。你可以将特定工具设置为 `true` 或 `false` 来启用或禁用它们。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -如果未指定任何工具,则默认启用所有工具。 - ---- - -#### 可用工具 - -以下是所有可通过模式配置控制的工具。 - -| 工具 | 描述 | -| ----------- | ---------------- | -| `bash` | 执行 shell 命令 | -| `edit` | 修改现有文件 | -| `write` | 创建新文件 | -| `read` | 读取文件内容 | -| `grep` | 搜索文件内容 | -| `glob` | 按模式查找文件 | -| `patch` | 对文件应用补丁 | -| `todowrite` | 管理待办事项列表 | -| `webfetch` | 获取网页内容 | - ---- - -## 自定义模式 - -你可以通过在配置中添加自定义模式来创建自己的模式。以下是两种方式的示例: - -### 使用 JSON 配置 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### 使用 Markdown 文件 - -在 `.opencode/modes/` 中创建项目特定的模式文件,或在 `~/.config/opencode/modes/` 中创建全局模式文件: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 使用场景 - -以下是不同模式的一些常见使用场景。 - -- **Build 模式**:启用所有工具的完整开发工作 -- **Plan 模式**:分析和规划,不做任何更改 -- **Review 模式**:使用只读访问权限加文档工具进行代码审查 -- **Debug 模式**:启用 bash 和读取工具,专注于问题排查 -- **Docs 模式**:支持文件操作但不支持系统命令的文档编写 - -你可能还会发现不同的模型适用于不同的使用场景。 diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 80dfe1e93d..9c0a5d8a3b 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。 +1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。 -2. 执行 `/connect` 命令并搜索 **Firmware**。 +2. 执行 `/connect` 命令并搜索 **FrogBot**。 ```txt /connect ``` -3. 输入你的 Firmware API 密钥。 +3. 输入你的 FrogBot API 密钥。 ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx index 4df9d13fdd..0d51bff2f8 100644 --- a/packages/web/src/content/docs/zh-tw/cli.mdx +++ b/packages/web/src/content/docs/zh-tw/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------ | ---- | ------------------------------------------------------------- | -| `--continue` | `-c` | 繼續上一個工作階段 | -| `--session` | `-s` | 要繼續的工作階段 ID | -| `--fork` | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | -| `--prompt` | | 要使用的提示詞 | -| `--model` | `-m` | 要使用的模型,格式為 provider/model | -| `--agent` | | 要使用的代理 | -| `--port` | | 監聽連接埠 | -| `--hostname` | | 監聽主機名稱 | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ------------------------------------------------------------- | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--prompt"} | | 要使用的提示詞 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 監聽連接埠 | +| {"--hostname"} | | 監聽主機名稱 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ----------- | ---- | ------------------- | -| `--dir` | | 啟動 TUI 的工作目錄 | -| `--session` | `-s` | 要繼續的工作階段 ID | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--dir"} | | 啟動 TUI 的工作目錄 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分支工作階段(與 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 旗標 -| 旗標 | 說明 | -| --------- | ------------------------------ | -| `--event` | 用於執行代理的 GitHub 模擬事件 | -| `--token` | GitHub 個人存取權杖 | +| 旗標 | 說明 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用於執行代理的 GitHub 模擬事件 | +| {"--token"} | GitHub 個人存取權杖 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 旗標 -| 旗標 | 說明 | -| ----------- | ------------------------------------------ | -| `--refresh` | 從 models.dev 重新整理模型快取 | -| `--verbose` | 使用更詳細的模型輸出(包含費用等中繼資料) | +| 旗標 | 說明 | +| --------------------------------------- | ------------------------------------------ | +| {"--refresh"} | 從 models.dev 重新整理模型快取 | +| {"--verbose"} | 使用更詳細的模型輸出(包含費用等中繼資料) | 使用 `--refresh` 旗標可以更新快取的模型列表。當供應商新增了模型並且您希望在 OpenCode 中看到它們時,此功能非常有用。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------ | ---- | -------------------------------------------------------------- | -| `--command` | | 要執行的指令,使用 message 作為參數 | -| `--continue` | `-c` | 繼續上一個工作階段 | -| `--session` | `-s` | 要繼續的工作階段 ID | -| `--fork` | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | -| `--share` | | 分享工作階段 | -| `--model` | `-m` | 要使用的模型,格式為 provider/model | -| `--agent` | | 要使用的代理 | -| `--file` | `-f` | 附加到訊息的檔案 | -| `--format` | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | -| `--title` | | 工作階段標題(未提供值時使用截斷的提示詞) | -| `--attach` | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | -| `--port` | | 本地伺服器連接埠(預設為隨機連接埠) | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--command"} | | 要執行的指令,使用 message 作為參數 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--share"} | | 分享工作階段 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到訊息的檔案 | +| {"--format"} | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | +| {"--title"} | | 工作階段標題(未提供值時使用截斷的提示詞) | +| {"--attach"} | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 執行目錄,或附加時遠端伺服器上的路徑 | +| {"--variant"} | | 模型變體(特定於提供者的推理級別) | +| {"--thinking"} | | 顯示思考區塊 | +| {"--port"} | | 本地伺服器連接埠(預設為隨機連接埠) | --- @@ -364,12 +373,12 @@ opencode serve #### 旗標 -| 旗標 | 說明 | -| ------------ | -------------------------- | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | -| `--mdns` | 啟用 mDNS 探索 | -| `--cors` | 允許 CORS 的額外瀏覽器來源 | +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | --- @@ -393,10 +402,10 @@ opencode session list ##### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------- | ---- | ------------------------------------- | -| `--max-count` | `-n` | 限制為最近 N 個工作階段 | -| `--format` | | 輸出格式:table 或 json(預設 table) | +| 旗標 | 簡寫 | 說明 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制為最近 N 個工作階段 | +| {"--format"} | | 輸出格式:table 或 json(預設 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 旗標 -| 旗標 | 說明 | -| ----------- | ---------------------------------------------------- | -| `--days` | 顯示最近 N 天的統計資訊(預設為所有時間) | -| `--tools` | 顯示的工具數量(預設為全部) | -| `--models` | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | -| `--project` | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | +| 旗標 | 說明 | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | 顯示最近 N 天的統計資訊(預設為所有時間) | +| {"--tools"} | 顯示的工具數量(預設為全部) | +| {"--models"} | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | +| {"--project"} | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | --- @@ -460,12 +469,12 @@ opencode web #### 旗標 -| 旗標 | 說明 | -| ------------ | -------------------------- | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | -| `--mdns` | 啟用 mDNS 探索 | -| `--cors` | 允許 CORS 的額外瀏覽器來源 | +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | --- @@ -481,11 +490,11 @@ opencode acp #### 旗標 -| 旗標 | 說明 | -| ------------ | ------------ | -| `--cwd` | 工作目錄 | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | +| 旗標 | 說明 | +| ---------------------------------------- | ------------ | +| {"--cwd"} | 工作目錄 | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 旗標 -| 旗標 | 簡寫 | 說明 | -| --------------- | ---- | ------------------------------ | -| `--keep-config` | `-c` | 保留設定檔 | -| `--keep-data` | `-d` | 保留工作階段資料和快照 | -| `--dry-run` | | 顯示將被刪除的內容但不實際刪除 | -| `--force` | `-f` | 跳過確認提示 | +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留設定檔 | +| {"--keep-data"} | `-d` | 保留工作階段資料和快照 | +| {"--dry-run"} | | 顯示將被刪除的內容但不實際刪除 | +| {"--force"} | `-f` | 跳過確認提示 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | +| 旗標 | 簡寫 | 說明 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI 接受以下全域旗標。 -| 旗標 | 簡寫 | 說明 | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | 顯示說明資訊 | -| `--version` | `-v` | 印出版本號 | -| `--print-logs` | | 將日誌輸出到 stderr | -| `--log-level` | | 日誌等級(DEBUG、INFO、WARN、ERROR) | +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 顯示說明資訊 | +| {"--version"} | `-v` | 印出版本號 | +| {"--print-logs"} | | 將日誌輸出到 stderr | +| {"--log-level"} | | 日誌等級(DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/zh-tw/modes.mdx b/packages/web/src/content/docs/zh-tw/modes.mdx deleted file mode 100644 index 625b4d0219..0000000000 --- a/packages/web/src/content/docs/zh-tw/modes.mdx +++ /dev/null @@ -1,326 +0,0 @@ ---- -title: 模式 -description: 不同模式適用於不同的使用情境。 ---- - -:::caution -模式現在透過 opencode 設定中的 `agent` 選項進行設定。`mode` 選項已廢棄。[了解更多](/docs/agents)。 -::: - -opencode 中的模式允許您為不同的使用情境自訂行為、工具和提示詞。 - -opencode 自帶兩種內建模式:**build** 和 **plan**。您可以自訂這些模式,也可以透過 opencode 設定建立自己的模式。 - -您可以在工作階段中切換模式,也可以在設定檔中進行設定。 - ---- - -## 內建模式 - -opencode 自帶兩種內建模式。 - ---- - -### Build - -Build 是啟用了所有工具的**預設**模式。這是進行開發工作的標準模式,您可以完全存取檔案操作和系統指令。 - ---- - -### Plan - -Plan 是一種為規劃和分析設計的受限模式。在 plan 模式下,以下工具預設被停用: - -- `write` - 無法建立新檔案 -- `edit` - 無法修改現有檔案,但位於 `.opencode/plans/*.md` 的檔案除外,用於詳細說明計畫本身 -- `patch` - 無法套用補丁 -- `bash` - 無法執行 shell 指令 - -當您希望 AI 分析程式碼、提出修改建議或制定計畫,而不對程式碼庫進行任何實際更改時,此模式非常有用。 - ---- - -## 切換 - -您可以在工作階段中使用 _Tab_ 鍵切換模式,或者使用您設定的 `switch_mode` 快捷鍵。 - -另請參閱:[格式化工具](/docs/formatters)了解程式碼格式化設定的相關資訊。 - ---- - -## 設定 - -您可以自訂內建模式或透過設定建立自己的模式。模式可以透過兩種方式進行設定: - -### JSON 設定 - -在 `opencode.json` 設定檔中設定模式: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "build": { - "model": "anthropic/claude-sonnet-4-20250514", - "prompt": "{file:./prompts/build.txt}", - "tools": { - "write": true, - "edit": true, - "bash": true - } - }, - "plan": { - "model": "anthropic/claude-haiku-4-20250514", - "tools": { - "write": false, - "edit": false, - "bash": false - } - } - } -} -``` - -### Markdown 設定 - -您還可以使用 Markdown 檔案定義模式。將檔案放置在以下位置: - -- 全域:`~/.config/opencode/modes/` -- 專案:`.opencode/modes/` - -```markdown title="~/.config/opencode/modes/review.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.1 -tools: - write: false - edit: false - bash: false ---- - -You are in code review mode. Focus on: - -- Code quality and best practices -- Potential bugs and edge cases -- Performance implications -- Security considerations - -Provide constructive feedback without making direct changes. -``` - -Markdown 檔案名稱即為模式名稱(例如,`review.md` 建立一個名為 `review` 的模式)。 - -下面讓我們詳細了解這些設定選項。 - ---- - -### 模型 - -使用 `model` 設定可以覆寫該模式的預設模型。這對於針對不同任務使用不同模型非常有用。例如,規劃時使用更快的模型,實作時使用更強大的模型。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "model": "anthropic/claude-haiku-4-20250514" - } - } -} -``` - ---- - -### 溫度 - -使用 `temperature` 設定控制 AI 回應的隨機性和創造性。較低的值使回應更加集中和確定性,較高的值則增加創造性和多樣性。 - -```json title="opencode.json" -{ - "mode": { - "plan": { - "temperature": 0.1 - }, - "creative": { - "temperature": 0.8 - } - } -} -``` - -溫度值的範圍通常為 0.0 到 1.0: - -- **0.0-0.2**:非常集中且確定性高的回應,適合程式碼分析和規劃 -- **0.3-0.5**:兼顧穩定性與創造力的平衡型回應,適合一般開發任務 -- **0.6-1.0**:更具創造性和多樣性的回應,適合腦力激盪和探索性工作 - -```json title="opencode.json" -{ - "mode": { - "analyze": { - "temperature": 0.1, - "prompt": "{file:./prompts/analysis.txt}" - }, - "build": { - "temperature": 0.3 - }, - "brainstorm": { - "temperature": 0.7, - "prompt": "{file:./prompts/creative.txt}" - } - } -} -``` - -如果未指定溫度,opencode 將使用模型特定的預設值(大多數模型通常為 0,Qwen 模型為 0.55)。 - ---- - -### 提示詞 - -使用 `prompt` 設定為模式指定自訂系統提示詞檔案。提示詞檔案應包含針對該模式用途的具體指令。 - -```json title="opencode.json" -{ - "mode": { - "review": { - "prompt": "{file:./prompts/code-review.txt}" - } - } -} -``` - -此路徑相對於設定檔所在位置。因此,全域 opencode 設定和專案特定設定均可使用。 - ---- - -### 工具 - -使用 `tools` 設定控制該模式下可用的工具。您可以將特定工具設定為 `true` 或 `false` 來啟用或停用它們。 - -```json -{ - "mode": { - "readonly": { - "tools": { - "write": false, - "edit": false, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -如果未指定任何工具,則預設啟用所有工具。 - ---- - -#### 可用工具 - -以下是所有可透過模式設定控制的工具。 - -| 工具 | 描述 | -| ----------- | ---------------- | -| `bash` | 執行 shell 指令 | -| `edit` | 修改現有檔案 | -| `write` | 建立新檔案 | -| `read` | 讀取檔案內容 | -| `grep` | 搜尋檔案內容 | -| `glob` | 按模式尋找檔案 | -| `patch` | 對檔案套用補丁 | -| `todowrite` | 管理待辦事項清單 | -| `webfetch` | 擷取網頁內容 | - ---- - -## 自訂模式 - -您可以透過在設定中新增自訂模式來建立自己的模式。以下是兩種方式的範例: - -### 使用 JSON 設定 - -```json title="opencode.json" {4-14} -{ - "$schema": "https://opencode.ai/config.json", - "mode": { - "docs": { - "prompt": "{file:./prompts/documentation.txt}", - "tools": { - "write": true, - "edit": true, - "bash": false, - "read": true, - "grep": true, - "glob": true - } - } - } -} -``` - -### 使用 Markdown 檔案 - -在 `.opencode/modes/` 中建立專案特定的模式檔案,或在 `~/.config/opencode/modes/` 中建立全域模式檔案: - -```markdown title=".opencode/modes/debug.md" ---- -temperature: 0.1 -tools: - bash: true - read: true - grep: true - write: false - edit: false ---- - -You are in debug mode. Your primary goal is to help investigate and diagnose issues. - -Focus on: - -- Understanding the problem through careful analysis -- Using bash commands to inspect system state -- Reading relevant files and logs -- Searching for patterns and anomalies -- Providing clear explanations of findings - -Do not make any changes to files. Only investigate and report. -``` - -```markdown title="~/.config/opencode/modes/refactor.md" ---- -model: anthropic/claude-sonnet-4-20250514 -temperature: 0.2 -tools: - edit: true - read: true - grep: true - glob: true ---- - -You are in refactoring mode. Focus on improving code quality without changing functionality. - -Priorities: - -- Improve code readability and maintainability -- Apply consistent naming conventions -- Reduce code duplication -- Optimize performance where appropriate -- Ensure all tests continue to pass -``` - ---- - -### 使用情境 - -以下是不同模式的一些常見使用情境。 - -- **Build 模式**:啟用所有工具的完整開發工作 -- **Plan 模式**:分析和規劃,不做任何更改 -- **Review 模式**:使用唯讀存取權限加文件工具進行程式碼審查 -- **Debug 模式**:啟用 bash 和讀取工具,專注於問題排查 -- **Docs 模式**:支援檔案操作但不支援系統指令的文件編寫 - -您可能還會發現不同的模型適用於不同的使用情境。 diff --git a/packages/web/src/content/docs/zh-tw/providers.mdx b/packages/web/src/content/docs/zh-tw/providers.mdx index c874170959..d4e55ed712 100644 --- a/packages/web/src/content/docs/zh-tw/providers.mdx +++ b/packages/web/src/content/docs/zh-tw/providers.mdx @@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。 +1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。 -2. 執行 `/connect` 指令並搜尋 **Firmware**。 +2. 執行 `/connect` 指令並搜尋 **FrogBot**。 ```txt /connect ``` -3. 輸入您的 Firmware API 金鑰。 +3. 輸入您的 FrogBot API 金鑰。 ```txt ┌ API key diff --git a/script/raw-changelog.ts b/script/raw-changelog.ts index 735b078be1..c571de322a 100644 --- a/script/raw-changelog.ts +++ b/script/raw-changelog.ts @@ -82,6 +82,11 @@ function section(areas: Set) { return "Core" } +function type(message: string) { + if (message.match(/fix/i)) return "Bugfixes" + return "Improvements" +} + function reverted(commits: Commit[]) { const seen = new Map() @@ -193,13 +198,20 @@ async function thanks(from: string, to: string, reuse: boolean) { } function format(from: string, to: string, list: Commit[], thanks: string[]) { - const grouped = new Map() - for (const title of order) grouped.set(title, []) + const grouped = new Map>() + for (const title of order) { + grouped.set( + title, + new Map([ + ["Improvements", []], + ["Bugfixes", []], + ]), + ) + } for (const commit of list) { - const title = section(commit.areas) const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" - grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) + grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) } const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""] @@ -209,11 +221,23 @@ function format(from: string, to: string, list: Commit[], thanks: string[]) { } for (const title of order) { - const entries = grouped.get(title) - if (!entries || entries.length === 0) continue + const groups = grouped.get(title) + if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue lines.push(`## ${title}`) - lines.push(...entries) - lines.push("") + const improvements = groups.get("Improvements")! + const bugfixes = groups.get("Bugfixes")! + if (bugfixes.length === 0) { + lines.push(...improvements) + lines.push("") + continue + } + + for (const [subtitle, entries] of groups) { + if (entries.length === 0) continue + lines.push(`### ${subtitle}`) + lines.push(...entries) + lines.push("") + } } if (thanks.length > 0) { diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 2d37834173..22d8adc54b 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.30", + "version": "1.14.35", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/specs/v2/todo.md b/specs/v2/todo.md new file mode 100644 index 0000000000..77c650e55f --- /dev/null +++ b/specs/v2/todo.md @@ -0,0 +1,59 @@ +# TODO + +ok we need to work towards a launch of v2 so we can get out of this rebuild phase + +## Kill Hono - Kit + +Hono needs to go away so zod can go away. this is almost done + +## New Data Mode - Dax + +This is mostly done. I'm working through modeling subagents, skill invocations +and shell commands. + +## Rework agent loop - Kit? + +I think this needs to be done so we can take advantage of the simpler data +model. It can stop doing all the + +## Rework compaction - Aiden? + +The new agent loop needs to trigger compaction properly + +## Plugin API design - James? + +We need to figure out how we want server plugins to work and what hooks are useful. + +Some ideas: + +- plugins get immer drafts so bad mutations can be thrown away +- plugins get global "opencode" instance like in that post i showed +- opencode instance has stuff like `opencode.session.prompt()` or + `opencode.tool.register({...})` + +## Rework Config - ??? + +We should do another pass on config to clean up any mistakes we made with it and +simplify as much as possible. Old configs should get auto-converted to new + +## Auth - ??? + +I have a basic auth system that can track any kind of auth, not just providers + +## Model Database - ??? + +I have a basic model service that allows for models to be registered dynamically + +## Provider - ??? + +Providers should register as plugins and autoload based on whatever logic they +want / config. They should register models into model database + +## Event - Kit + +I have this v2/event.ts but it needs to be self contained instead of using the +old bus system + +## Everything is hotreloadable - ??? + +Instead of needing to tear down things when something changes every service should emit granular events so services can react to them and reconfigure themselves. Allows frontend to receive these too, eg model.added. also prevents startup from blocking diff --git a/turbo.json b/turbo.json index 28c2fa2de0..0183fabca4 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,15 @@ "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] + }, + "@opencode-ai/ui#test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "@opencode-ai/ui#test:ci": { + "dependsOn": ["^build"], + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] } } }